Compare commits

108 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
Daeng Deni Mardaeni
336ef8cf3a feat(webstatement): perbarui logika pemrosesan combine PDF dan parameter periode
- Memperbarui fungsi `combinePdfs` di `CombinePdfController`:
  - Menghapus penggunaan parameter dari request dan mengganti dengan parameter langsung `$period`.
  - Menambahkan filter `branch_code` `ID0010001` pada pemanggilan data akun untuk memastikan hanya akun tertentu yang diproses.
  - Mengubah jalur pencarian file PDF ke direktori baru: `app/STMT/r14` dan `app/STMT/r23` untuk menyelaraskan struktur penyimpanan file.

- Memperbarui command `webstatement:combine-pdf`:
  - Menambahkan opsi baru `--period` untuk menyederhanakan pengaturan periode penggabungan PDF melalui format `Ym` (contoh: 202506).
  - Menghapus penggunaan `request()` pada command untuk memaksimalkan pengelolaan periode langsung dari opsi command-line.

- Tujuan pembaruan ini:
  - Memastikan proses combine PDF hanya memproses data relevan berdasarkan filter branch dan struktur direktori baru.
  - Menyempurnakan fleksibilitas parameter periode pada command-line untuk mengurangi dependensi terhadap input request.
  - Meningkatkan konsistensi dan efisiensi dalam pengelolaan file PDF sesuai periode dan filter branch tertentu.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 18:55:29 +07:00
Daeng Deni Mardaeni
b71fc1b3f9 feat(webstatement): ubah format respons menjadi JSON pada fungsi ekspor pernyataan
- Memperbarui fungsi dalam `WebstatementController`:
  - Mengganti format respons sebelumnya dengan `array` menjadi `response()->json()` untuk semua respon sukses maupun error.
  - Menyesuaikan return pada kasus keberhasilan pengantrean job ekspor pernyataan.
  - Menambahkan handling kesalahan menggunakan `response()->json()` untuk memberikan informasi error yang lebih terstruktur dan konsisten.

- Perubahan ini bertujuan untuk:
  - Mengkonsolidasikan format respons API menjadi JSON agar lebih sesuai dengan praktik terbaik pengembangan API.
  - Mempermudah pengguna dalam memproses respons API, terutama yang bekerja dengan data JSON.
  - Meningkatkan konsistensi logika penanganan respons dalam aplikasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 15:28:41 +07:00
Daeng Deni Mardaeni
0d8a4c1ba4 feat(webstatement): tambahkan command untuk ekspor pernyataan rekening periode
- Menambahkan kelas command `ExportPeriodStatements` ke dalam register provider `WebstatementServiceProvider`.
- Memungkinkan penggunaan command ini untuk mengolah dan mengekspor data pernyataan rekening dengan periode tertentu.
- Menyempurnakan fungsionalitas ekspor data untuk mendukung kebutuhan laporan sesuai interval waktu yang ditentukan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 15:25:37 +07:00
Daeng Deni Mardaeni
e5f3a67374 feat(webstatement): tambahkan command untuk ekspor pernyataan rekening berdasarkan periode
- Menambahkan command baru `webstatement:export-period-statements`:
  - Mendukung opsi parameter `--account_number` dan `--period` untuk menentukan rekening dan periode ekspor.
  - Memproses data pernyataan rekening dengan memanggil fungsi `printStatementRekening` pada `WebstatementController`.
  - Menampilkan log proses, termasuk jumlah job ekspor yang berhasil diajukan dan pesan error jika terjadi.

- Memperbarui fungsi `printStatementRekening` di `WebstatementController`:
  - Menambahkan parameter input `$accountNumber` dan `$period` untuk mendukung multi-periode secara dinamis.
  - Menyesuaikan periode default ke bulan dan tahun saat ini jika parameter tidak disediakan.
  - Memvalidasi dan mengambil data saldo berdasarkan `account_number` dan `period`.

- Tujuan pembaruan ini:
  - Mendukung efisiensi proses ekspor data rekening dalam periode tertentu.
  - Memberikan fleksibilitas lebih dalam command-line operasi pengolahan data pernyataan.
  - Menyediakan feedback proses yang jelas kepada pengguna, baik sukses maupun error.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 15:20:14 +07:00
Daeng Deni Mardaeni
701432a6e7 feat(webstatement): sesuaikan logika pemrosesan parameter migrasi harian
- Memperbarui pemrosesan parameter pada `ProcessDailyMigration`:
  - Mengubah logika pengiriman parameter `process_parameter` ke `MigrasiController`:
    - Sebelumnya mengirimkan parameter dalam bentuk array.
    - Sekarang parameter dikirimkan langsung tanpa pembungkusan array.
  - Memastikan parameter diterima dan diproses sesuai dengan perubahan pada controller.

- Memodifikasi fungsi `index` pada `MigrasiController`:
  - Menambahkan parameter opsional `$processParameter` pada fungsi.
  - Mengganti penggunaan `request('process_parameter')` dengan langsung memeriksa `$processParameter`.
  - Menghilangkan dependensi langsung terhadap input request untuk meningkatkan fleksibilitas pemrosesan.

- Tujuan pembaruan ini:
  - Menyederhanakan struktur parameter yang digunakan dalam pemrosesan migrasi harian.
  - Mengurangi gangguan yang mungkin terjadi akibat ketergantungan terhadap input langsung dari request.
  - Memastikan konsistensi dan kompatibilitas pengiriman parameter dari command ke controller.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 15:09:08 +07:00
Daeng Deni Mardaeni
c1c7f03c87 feat(webstatement): perbaiki endpoint debug test-statement
- Memperbarui rute pada file `web.php`:
  - Mengubah fungsi controller untuk endpoint `/debug/test-statement` dari `index` menjadi `printStatementRekening` pada `WebstatementController`.

- Tujuan pembaruan:
  - Mengarahkan fungsi endpoint ke proses yang sesuai (cetak statement rekening).
  - Memastikan konsistensi dan keakuratan logika pada pengelolaan rute.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 15:03:47 +07:00
Daeng Deni Mardaeni
d4efa58f1b feat(webstatement): tambahkan fitur ekspor statement rekening
- Menambahkan fungsi `printStatementRekening` pada `WebstatementController` untuk mendukung ekspor statement rekening:
  - Mengambil saldo rekening berdasarkan `account_number` dan `period`.
  - Melakukan validasi input seperti `accountNumber`, `period`, dan `clientName`.
  - Menambah log proses ekspor, termasuk saat fungsi dijalankan, keberhasilan pengiriman job, dan error jika terjadi.
  - Mengantrekan job `ExportStatementPeriodJob` dengan parameter seperti `account_number`, `period`, `balance`, dan `client_name`.
  - Menangani error dengan logging detail kegagalan ekspor dan memberikan respon yang sesuai.

- Memperbarui rute pada file `web.php`:
  - Menambahkan endpoint baru `/debug/test-statement` untuk debugging ekspor statement menggunakan controller `WebstatementController`.

- Tujuan perubahan ini:
  - Mendukung proses ekspor data statement rekening secara terstruktur.
  - Memberikan kemudahan debugging dan pelacakan proses ekspor.
  - Memastikan fleksibilitas dalam pengelolaan saldo dan data rekening.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 14:52:35 +07:00
Daeng Deni Mardaeni
f624a629f5 feat(webstatement): sesuaikan opsi parameter proses pada migrasi harian
- Menghapus opsi `--date` dan `--type` dari command `webstatement:process-daily-migration` dan menggantinya dengan opsi baru `--process_parameter`.
- Memperbarui pesan log pada eksekusi command untuk mencatat nilai `process_parameter` sebagai pengganti parameter `date` dan `type`.
- Memperbarui logika pengiriman parameter ke controller:
  - Sebelumnya mengirimkan `date` dan `type`, kini diganti menjadi `process_parameter`.
- Tujuan perubahan ini:
  - Menyederhanakan pengaturan parameter untuk command migrasi harian.
  - Memberikan fleksibilitas lebih dalam penanganan parameter proses migrasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 14:50:37 +07:00
Daeng Deni Mardaeni
10fcdb5ea2 feat(webstatement): tambahkan job untuk ekspor data pernyataan periode ke CSV
- Menambahkan **`ExportStatementPeriodJob`** untuk mendukung ekspor data pernyataan periode ke file CSV:
  - Inisialisasi job dengan parameter `account_number`, `period`, `saldo`, `client`, dan `disk`.
  - Menghitung tanggal mulai (`startDate`) dan tanggal akhir (`endDate`) berdasarkan periode yang diberikan.
  - Log informasi eksekusi termasuk waktu mulai, rentang tanggal, dan hasil akhir.
  - Menangani error saat proses berjalan dengan logging terperinci.

- Memproses data pernyataan sebelum ekspor, termasuk:
  - Melakukan validasi jumlah data yang telah diproses sebelumnya.
  - Jika data belum sepenuhnya diproses, menghapus data lama pada tabel `processed_statements`.
  - Memproses data baru dengan mengolah entri dari tabel `StmtEntry`.
  - Membuat narrative dari data transaksi menggunakan model terkait `TempFundsTransfer`, `TempStmtNarrParam`, dan `TempStmtNarrFormat`.

- Menambahkan logika untuk format tanggal transaksi dan narrasi:
  - Format tanggal transaksi melalui fungsi `formatTransactionDate` dan `formatActualDate`.
  - Narasi dihasilkan dari berbagai sumber, termasuk data transaksi dan parameter lainnya.

- Ekspor data ke file CSV:
  - Mengatur struktur folder berdasarkan `client` dan `account_number`.
  - Menghapus file lama sebelum membuat file baru untuk menghindari duplikasi.
  - Membagi data menjadi chunks untuk mengurangi penggunaan memori.
  - Menambahkan header CSV dengan format kolom: `NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE`.

- Optimasi proses:
  - Proses data dalam batch menggunakan pagination (`chunk`) untuk efisiensi memori.
  - Simpan hasil processed statement ke database sementara (`processed_statements`).
  - Tambahkan log setiap entri data untuk memonitor keberhasilan proses.

- Tujuan penambahan ini:
  - Mendukung proses pengolahan dan pelaporan data rekening secara terstruktur.
  - Meningkatkan efisiensi penyimpanan dan akses data besar.
  - Menghasilkan file CSV yang dapat digunakan sebagai dokumen untuk laporan eksternal.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-04 14:47:14 +07:00
Daeng Deni Mardaeni
2d07783c46 feat(webstatement): tambahkan fitur debugging untuk entri pernyataan
- Menambahkan **`DebugStatementController`** untuk mendukung fitur debugging entri pernyataan:
  - Fungsi `debugStatement` untuk menganalisis satu entri pernyataan berdasarkan `account_number`, `trans_reference`, dan `period` (opsional).
    - Melakukan validasi masukan dari permintaan.
    - Mengambil detail entri berdasarkan kriteria yang diberikan.
    - Menghasilkan `narrative`, informasi tanggal terformat, dan detail debug.
    - Memberikan detail data terkait seperti `ft` dan `transaction`.
    - Melakukan penanganan dan log error jika terjadi kegagalan proses.
  - Fungsi `listStatements` untuk mendapatkan daftar entri pernyataan berdasarkan kriteria tertentu.
    - Validasi dan dukungan parameter `account_number`, `period`, dan `limit`.
    - Pengurutan hasil berdasarkan `date_time` secara menurun.
    - Menampilkan hasil dalam format JSON termasuk jumlah total data.

- Menambahkan logika tambahan untuk:
  - Format tanggal transaksi dan tanggal aktual secara konsisten menggunakan `Carbon`.
  - Mendukung pembuatan `narrative` dengan data dari entri pernyataan.
  - Mengambil parameter narasi dan formatting berdasarkan tipe narasi melalui model `TempStmtNarrParam` dan `TempStmtNarrFormat`.

- Memperbarui routing dalam file `web.php` dengan menambahkan prefix `debug`:
  - **`POST /debug/statement`** -> debug satu entri pernyataan.
  - **`GET /debug/statements`** -> daftar semua entri untuk debugging.

- Tujuan pembaruan ini:
  - Mempermudah proses analisis dan troubleshooting pada entri pernyataan.
  - Memberikan informasi detail terkait kesalahan atau ketidaksesuaian data.
  - Membantu pengembang dan pengguna menganalisis informasi transaksi secara mendalam.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-03 20:29:44 +07:00
Daeng Deni Mardaeni
66f84600eb feat(webstatement): tambahkan kolom baru sektor, tipe pelanggan, dan tanggal lahir/pendirian pada model Customer
- Memperbarui model `Customer` dengan menambahkan properti baru pada `$fillable`:
  - `sector`
  - `customer_type`
  - `birth_incorp_date`
- Menambahkan migrasi baru `add_sector_customer_type_birth_incorp_date_to_customers_table`:
  - Menambahkan kolom `sector`, `customer_type`, dan `birth_incorp_date` pada tabel `customers`.
  - Semua kolom bersifat nullable untuk menjaga kompatibilitas data lama.
  - Menyediakan fungsi rollback dengan menghapus kolom yang ditambahkan.
- Tujuan perubahan ini:
  - Mendukung penyimpanan data sektor, tipe pelanggan, dan tanggal lahir/pendirian pada entitas pelanggan.
  - Memfasilitasi validasi data tambahan dalam proses bisnis dan laporan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-03 12:02:31 +07:00
Daeng Deni Mardaeni
cc99883875 feat(webstatement): tambahkan fitur pemrosesan data sektor dan parameter pada migrasi harian
- Menambahkan opsi baru `--process_parameter` pada command `webstatement:process-daily-migration`.
  - Memungkinkan pengguna untuk menentukan parameter proses migrasi seperti tanggal (`date`) dan tipe (`type`).
  - Menambahkan logging tambahan untuk mencatat nilai parameter yang diproviding pengguna.
- Memperbarui logika command dan controller:
  - Mengirimkan parameter `date` dan `type` ke controller untuk mendukung proses migrasi dengan parameter yang lebih spesifik.
- Menambahkan proses migrasi baru untuk data sektor:
  - Membuat job `ProcessSectorDataJob` yang bertugas membaca file CSV terkait sektor dari SFTP.
  - Melakukan validasi keberadaan file, memproses tiap baris data, dan menyimpannya ke database jika valid.
  - Logging untuk setiap aktivitas proses sektor, termasuk error dan kesuksesan per baris.
- Membuat model `Sector` untuk mendukung operasi database data sektor:
  - Menambah atribut dapat diolah (`fillable`) seperti `sector_code`, `co_code`, `description`, dll.
  - Menambahkan cast `date_time` ke tipe datetime.
- Menambahkan migrasi baru untuk tabel `sectors`:
  - Tabel memiliki kolom seperti `id`, `date_time`, `description`, `curr_no`, `co_code`, dan `sector_code`.
  - Meningkatkan pendukung penyimpanan data sektor untuk migrasi masa depan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-03 11:58:16 +07:00
Daeng Deni Mardaeni
6b8f44db1d feat(webstatement): tambahkan logika penghapusan dan penggantian file PDF setelah dekripsi
- Menambahkan variabel `$finalPdfPath` untuk menyimpan lintasan file PDF akhir tanpa ekstensi `.dec`.
- Mengimplementasikan logika baru untuk:
  1. Menghapus file PDF terenkripsi setelah file berhasil didekripsi.
  2. Mengganti nama file dekripsi dengan menghilangkan ekstensi `.dec`.
- Menambahkan logging baru untuk mencatat aktivitas berikut:
  1. Penghapusan file PDF terenkripsi setelah berhasil didekripsi.
  2. Perubahan nama file dekripsi ke format final.
- Tujuan perubahan ini adalah untuk:
  1. Mengoptimalkan ruang penyimpanan dengan menghilangkan file terenkripsi setelah digunakan.
  2. Memastikan hasil dekripsi langsung dapat digunakan tanpa perlu pengolahan manual tambahan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 20:08:24 +07:00
Daeng Deni Mardaeni
d326cce6e0 feat(webstatement): tambahkan fitur unlock PDF file dan jadwal otomatisasi harian
- Menambahkan command baru `webstatement:unlock-pdf` untuk membuka file PDF yang dilindungi password:
  - Dapat menerima parameter `directory` untuk menetapkan direktori file sumber.
  - Opsi `--password` untuk menentukan password yang digunakan dalam proses unlock, dengan default `123456`.
  - Menampilkan log proses unlock PDF dengan pesan sukses atau error.
- Membuat job baru `UnlockPdfJob` untuk menangani proses unlock PDF secara asinkron:
  - Memindai direktori utama berdasarkan struktur folder (tahun dan ID).
  - Membuka proteksi file PDF dengan menggunakan library `qpdf`.
  - Menghasilkan file PDF yang telah didekripsi di direktori yang sama dengan nama file ekstensi `.dec.pdf`.
  - Melakukan logging untuk setiap file yang berhasil diproses atau ketika terjadi error.
  - Menghindari duplikasi dengan memeriksa keberadaan file decrypted sebelumnya.
- Memperbarui `WebstatementServiceProvider`:
  - Mendaftarkan command `UnlockPdf` untuk digunakan dalam aplikasi.
  - Menambah jadwal otomatisasi harian pada pukul 09:30 untuk menjalankan command `webstatement:unlock-pdf`.
  - Logging hasil proses executables ke file `logs/unlock-pdf.log`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 19:59:33 +07:00
Daeng Deni Mardaeni
173b229f07 feat(webstatement): tambahkan pengaturan ukuran kertas A4 pada konversi HTML ke PDF
- Menambahkan pengaturan ukuran kertas A4 pada proses konversi HTML ke PDF di `ConvertHtmlToPdfJob`:
  - Menggunakan metode `setPaper('A4', 'portrait')` untuk menetapkan standar ukuran dan orientasi kertas.
- Memperbarui log proses konversi agar mencantumkan informasi ukuran kertas yang digunakan.
- Tujuan perubahan ini adalah memastikan tampilan PDF lebih terstandarisasi sesuai kebutuhan dokumen.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 19:21:10 +07:00
Daeng Deni Mardaeni
1a1fecd0ad feat(webstatement): tambahkan penjadwalan dan pendaftaran command ConvertHtmlToPdf
- Menambahkan command baru `ConvertHtmlToPdf` ke dalam `WebstatementServiceProvider`.
  - Mendaftarkan command ke dalam array commands untuk dapat digunakan di aplikasi.
- Memperbarui `schedule` di `WebstatementServiceProvider` untuk menjalankan command `webstatement:convert-html-to-pdf` secara otomatis.
  - Ditambahkan penjadwalan harian pada pukul 09:30.
  - Logging hasil eksekusi command ke dalam file log `logs/convert-html-to-pdf.log`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 19:07:26 +07:00
Daeng Deni Mardaeni
700c8bbbf6 feat(webstatement): tambahkan fitur konversi file HTML ke PDF
- Menambahkan command baru `webstatement:convert-html-to-pdf` untuk melakukan konversi file HTML menjadi PDF secara otomatis:
  - Dapat menerima parameter `directory` untuk menentukan direktori sumber file HTML.
  - Menampilkan pesan sukses atau error selama proses berjalan.
  - Menggunakan konsep asinkron melalui job untuk meningkatkan efisiensi.

- Membuat job baru `ConvertHtmlToPdfJob` untuk menangani proses konversi file:
  - Memproses folder yang berisi file HTML berdasarkan struktur direktori tertentu.
  - Mengambil semua file HTML dalam suatu folder, kemudian mengonversinya menjadi file PDF.
  - Menggunakan library `Barryvdh\DomPDF\Facade\Pdf` untuk konversi format HTML ke PDF.
  - Melakukan logging untuk setiap proses berhasil atau ketika terjadi error.
  - Memastikan suksesnya konversi ke direktori yang sama dengan file HTML.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 18:45:07 +07:00
Daeng Deni Mardaeni
8a728d6c6e feat(webstatement): tambahkan fitur penggabungan dan proteksi file PDF
- Menambahkan command baru `webstatement:combine-pdf` melalui `CombinePdf` untuk menjalankan proses penggabungan file PDF.
  - Proses ini mencakup penggabungan file PDF dari folder r14 dan r23 berdasarkan periode tertentu.
  - File PDF yang dihasilkan juga dilindungi dengan password berbasis nomor rekening.
- Membuat controller `CombinePdfController` dengan fungsi utama `combinePdfs` untuk mengontrol alur penggabungan file PDF:
  - Mendapatkan daftar akun yang relevan.
  - Mengecek file dari folder r14 dan r23 untuk setiap akun.
  - Melakukan logging saat file tidak ditemukan atau jika terdapat error dalam proses.
  - Mendaftarkan job `CombinePdfJob` untuk memproses file secara async.
- Menambahkan job baru `CombinePdfJob`:
  - Menggunakan library `PDFMerger` untuk menggabungkan file.
  - Terapkan proteksi password menggunakan library `PDFPasswordProtect`.
  - Memastikan direktori output dibuat jika belum ada.
  - Melakukan logging saat proses berhasil maupun saat terjadi error.
- Memperbarui `WebstatementServiceProvider`:
  - Mendaftarkan command baru ke dalam provider.
  - Menambahkan penjadwalan otomatis untuk menjalankan perintah `webstatement:combine-pdf` setiap hari pada pukul 09:30.
  - Logging hasil eksekusi ke file log `logs/combine-pdf.log`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-02 10:31:51 +07:00
Daeng Deni Mardaeni
8ca526e4f2 refactor(webstatement): optimalkan pemrosesan batch data bill detail
- Mengubah mekanisme pemrosesan batch `billDetailBatch` menjadi pemrosesan per chunk untuk manajemen memori yang lebih baik.
- Menambahkan langkah penghapusan data eksisting berdasarkan `stmt_entry_ids` sebelum melakukan insert untuk menghindari konflik data.
- Mengganti metode `upsert` dengan kombinasi `delete` dan `insert` untuk tiap chunk data.
- Menyisipkan komentar untuk memberikan penjelasan yang lebih rinci terkait proses chunking.
- Memastikan batch `billDetailBatch` di-reset setelah selesai diproses.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 21:58:08 +07:00
Daeng Deni Mardaeni
e14ae2ef9c refactor(webstatement): optimize ATM transaction processing
- Ubah mekanisme pengolahan batch transaksi ATM agar lebih efisien dan hemat memori.
- Hapus proses `upsert` secara langsung dan ganti dengan pendekatan berikut:
  1. Iterasi data batch dalam chunk kecil untuk menghindari konsumsi memori berlebih.
  2. Hapus data yang sudah ada berdasarkan `transaction_id` untuk menghindari konflik saat insert ulang.
  3. Gunakan metode `insert` untuk memasukkan data sekaligus per chunk.
- Tambahkan logika reset batch setelah selesai pengolahan.
- Tingkatkan skalabilitas dan stabilitas alur pengolahan data dengan mengurangi beban memori saat pemrosesan data besar.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 21:57:53 +07:00
Daeng Deni Mardaeni
7498d14087 refactor(webstatement): optimize ProcessTellerDataJob batching
Ubah alur proses batch pada job untuk meningkatkan efisiensi dan manajemen memori.

- Mengganti metode `upsert` dengan pendekatan manual yang memecah data batch menjadi potongan lebih kecil.
- Menambahkan proses penghapusan record lama berdasarkan `id_teller` sebelum melakukan insert baru untuk menghindari konflik data.
- Menggunakan loop untuk memproses batch dalam potongan-potongan kecil, sehingga mengurangi beban memori.
- Memastikan batch direset setelah selesai diproses untuk menghindari kebocoran data atau konflik.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 21:57:39 +07:00
Daeng Deni Mardaeni
369f24a8e2 refactor(webstatement): optimalkan pemrosesan batch transfer dana
- Mengubah mekanisme pemrosesan `transferBatch` menjadi berbasis chunk untuk mengoptimalkan penggunaan memori.
- Menambahkan logika penghapusan data lama dengan `_id` terkait sebelum menyisipkan data baru untuk menghindari konflik.
- Mengganti metode `upsert` dengan kombinasi `delete` dan `insert` untuk lebih fleksibel dalam penanganan data.
- Memastikan batch transfer diatur ulang setelah selesai diproses.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 21:57:23 +07:00
Daeng Deni Mardaeni
d455707dbc refactor(webstatement): optimalkan proses penyimpanan batch dalam ProcessStmtEntryDataJob
- Menghapus logika `updateOrCreate` dan menggantinya dengan penghapusan (`delete`) berdasarkan `stmt_entry_id` sebelum melakukan operasi `insert`.
- Menambahkan pembagian proses ke dalam chunk yang lebih kecil untuk manajemen memori yang lebih baik.
- Memastikan semua `stmt_entry_ids` dalam chunk diekstrak dan digunakan untuk meminimalisir konflik data.
- Menambah logging yang lebih detail dengan `e.getTraceAsString()` untuk mempermudah debugging ketika terdapat error.
- Membersihkan `entryBatch` baik setelah proses sukses maupun ketika terjadi kesalahan untuk mencegah reprocessing pada data yang sama.

Perubahan ini bertujuan untuk meningkatkan efisiensi dan memastikan integritas data selama proses penyimpanan batch.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 20:59:49 +07:00
Daeng Deni Mardaeni
76ebdce2ea fix(webstatement): ubah metode upsert menjadi updateOrCreate pada ProcessStmtEntryDataJob
- Refactor proses penyimpanan data pada `ProcessStmtEntryDataJob`:
  - Mengganti metode `StmtEntry::upsert` dengan `StmtEntry::updateOrCreate` untuk setiap entri dalam batch.
  - Metode `updateOrCreate` memungkinkan pembaruan data atau penyisipan data baru berdasarkan `stmt_entry_id` sebagai kunci unik.
- Meningkatkan fleksibilitas pembaruan data dengan menggunakan loop per entry dibandingkan bulk operation, sehingga lebih kompatibel untuk kasus tertentu.

- Tambahkan file migrasi baru untuk penyesuaian tabel `stmt_entry`:
  - File migrasi telah dibuat sebagai dasar, namun implementasi detail dalam tabel masih kosong.
  - File ini akan digunakan untuk perubahan skema di masa mendatang sesuai kebutuhan pengembangan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 19:40:35 +07:00
Daeng Deni Mardaeni
eed6c3dbaa feat(webstatement): tambahkan kolom dealer_desk pada mapping ProcessTellerDataJob
Penambahan kolom baru pada mapping untuk memperluas informasi yang tersedia dalam pemrosesan data teller.

Detail perubahan:
- Menambahkan key baru bernama `dealer_desk` pada property `$mapping` di file `ProcessTellerDataJob.php`.
- Memastikan data dari kolom `dealer_desk` menjadi bagian dari proses map data.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 09:56:30 +07:00
Daeng Deni Mardaeni
deb702c68e feat(webstatement): tambahkan kolom dealer_desk pada model dan tabel tellers
- Menambahkan atribut baru `dealer_desk` pada properti `$fillable` di model `Teller` untuk memungkinkan atribut ini diisi secara massal.
- Membuat migration baru `2025_05_29_024729_add_dealer_desk_to_tellers_table` untuk penambahan kolom `dealer_desk` ke dalam tabel `tellers`.
  - Kolom `dealer_desk` bertipe string dan dapat bernilai null.
  - Penempatan kolom dilakukan setelah kolom `last_version`.
- Menambahkan method untuk rollback migration yang menghapus kolom `dealer_desk` dari tabel `tellers`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 09:47:57 +07:00
Daeng Deni Mardaeni
1ae98bcc26 feat(webstatement): tambahkan field baru pada TempFundsTransfer dan migrasi terkait
- Menambahkan daftar field baru pada model `TempFundsTransfer` untuk mendukung pengelolaan data transfer dana sementara.
  - Field tambahan meliputi:
    - `at_unique_id`
    - `bif_ref_no`
    - `atm_order_id`
    - `api_iss_acct`
    - `api_benff_acct`
    - `remarks`
    - `api_mrchn_id`
    - `bif_rcv_acct`
    - `bif_snd_acct`
    - `bif_rcv_name`
    - `bif_va_no`

- Membuat file migrasi bernama `2025_05_29_015537_add_fields_to_temp_funds_transfer_table.php` untuk:
  - Menambahkan field baru pada tabel `temp_funds_transfer`.
  - Menyediakan mekanisme rollback dengan menghapus field yang ditambahkan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-29 08:57:10 +07:00
daengdeni
cbba58cc50 fix(webstatement): perbaiki logika penentuan nilai datetime pada ExportStatementJob
Perubahan dilakukan untuk mengatasi masalah terkait nilai default `datetime` yang tidak sesuai.

- Mengganti nilai default `datetime` dari `'0000000000'` menjadi `$item->date_time`.
- Memperbaiki logika pengisian nilai `datetime` agar mempertimbangkan nilai dari `$item->$relation?->date_time` terlebih dahulu sebelum menggunakan nilai default `$datetime`.
- Perubahan diterapkan pada dua blok kode dalam berkas `ExportStatementJob.php`.

Langkah ini memastikan nilai `datetime` lebih akurat dan konsisten sesuai data terkait.
2025-05-28 13:09:40 +07:00
daengdeni
c31f3c0d1f refactor(webstatement): remove unused date formatting logic in ProcessDataCaptureDataJob
Menghapus logika format tanggal yang tidak dipakai untuk menyederhanakan kode dan meningkatkan keterbacaan.

- Menghapus konstanta `DATE_FIELDS` yang tidak lagi digunakan.
- Menghapus fungsi `formatDates` beserta implementasinya yang berisi logika untuk format tanggal dan waktu.
- Menghilangkan pemanggilan fungsi `formatDates` pada loop proses data CSV.
- Optimasi kode untuk mengurangi kompleksitas dan potensi error terkait parsing tanggal/tanggal dan waktu.
- Memastikan data diproses tanpa perlu manipulasi format tanggal yang redundant.
2025-05-28 12:07:50 +07:00
daengdeni
762b1457ba feat(webstatement): update date formatting logic with dynamic relation mapping
- Tambahkan logika untuk memetakan prefix `trans_reference` ke hubungan objek terkait menggunakan `relationMap`.
- Update method untuk mengambil nilai `date_time` dari properti terkait berdasarkan mapping prefix (`FT`, `TT`, `DC`, `AA`) dan fallback ke default `0000000000` jika mapping tidak ditemukan.
- Modifikasi format tanggal menggunakan kombinasi `booking_date` dan parsing substring yang dinamis dari properti `date_time`.
- Perbaiki pengolahan format tanggal pada dua method yang terpengaruh untuk memastikan data yang lebih dinamis dan akurat.
- Tambahkan fallback error handling tetap menggunakan logging untuk mendeteksi potensi kesalahan format.
2025-05-28 10:38:27 +07:00
daengdeni
1b3e0ed30d feat(webstatement): implement batch processing for data jobs
- Tambahkan pemrosesan data secara bertahap (chunking) dengan konstanta `CHUNK_SIZE` untuk mengurangi penggunaan memori dan menangani data dalam jumlah besar.
- Perbarui `ProcessArrangementDataJob`:
  - Tambahkan properti `arrangementBatch` untuk menyimpan batch data sementara.
  - Implementasikan metode `addToBatch` untuk menambahkan data ke batch.
  - Implementasikan metode `saveBatch` untuk menyimpan data batch ke database menggunakan metode bulk `TempArrangement::upsert`.
  - Tambahkan logging untuk melacak progress pemrosesan data per chunk.
  - Reset batch setelah penyimpanan atau ketika terjadi error untuk menghindari pemrosesan ulang data yang gagal.
- Perbarui `ProcessBillDetailDataJob`:
  - Tambahkan properti `billDetailBatch` untuk menyimpan batch data sementara.
  - Implementasikan metode `addToBatch` untuk menambahkan data ke batch.
  - Implementasikan metode `saveBatch` untuk menyimpan data batch ke database menggunakan metode bulk `TempBillDetail::upsert`.
  - Tambahkan logging untuk melacak progress pemrosesan data per chunk.
  - Reset batch setelah penyimpanan atau ketika terjadi error untuk menghindari pemrosesan ulang data yang gagal.
- Perbaiki penghitungan error count dengan menambahkannya saat terjadi error pada pemrosesan batch.
- Tambahkan timestamp (`created_at` dan `updated_at`) pada setiap record dalam batch sebelum disimpan ke database.
- Lakukan cleanup batch secara otomatis setelah pemrosesan selesai.
2025-05-28 09:29:46 +07:00
daengdeni
0b607f86cb feat(webstatement): implement batch processing in job data handling
- Menambahkan konstanta `CHUNK_SIZE` dengan nilai 1000 untuk memungkinkan pemrosesan data dalam bentuk batch guna mengurangi penggunaan memori.
- Memperkenalkan atribut batch baru (`atmTransactionBatch`, `captureBatch`, `transferBatch`, dan `tellerBatch`) untuk menyimpan data sementara sebelum disimpan ke database.
- Mengganti metode penyimpanan langsung dengan menambahkan data ke batch melalui fungsi baru `addToBatch()`.
- Menambahkan fungsi `saveBatch()` untuk melakukan operasi penyimpanan data dalam jumlah besar (bulk) menggunakan metode `upsert`.
- Memastikan batch akan di-reset setelah penyimpanan, termasuk dalam kasus kegagalan, untuk mencegah reprocessing catatan yang gagal.
- Menambahkan logging tambahan untuk mencatat jumlah batch (chunk) yang telah selesai diproses, memberikan wawasan lebih dalam proses.
- Menambahkan validasi jumlah kolom pada setiap baris data, logging peringatan jika data tidak sesuai, dan mencatat jumlah kesalahan (`errorCount`).
- Secara otomatis menambahkan atribut timestamp (`created_at` dan `updated_at`) pada setiap data sebelum dimasukkan ke dalam batch untuk pelacakan waktu.
- Memodifikasi log error untuk menangani kesalahan pada level yang lebih spesifik, seperti pada baris tertentu dalam file yang diproses.
- Mengoptimalkan pemrosesan data pada job:
  - `ProcessAtmTransactionJob` untuk data transaksi ATM.
  - `ProcessDataCaptureDataJob` untuk data capture.
  - `ProcessFundsTransferDataJob` untuk data transfer dana.
  - `ProcessTellerDataJob` untuk data teller.
- Meningkatkan efisiensi dan skalabilitas dengan menyisipkan data secara bulk, mengurangi overhead database, dan menghindari pengolahan data secara satu per satu.
2025-05-28 09:29:18 +07:00
daengdeni
30662b97d5 feat(webstatement): optimize data processing with batching strategy
- Menambahkan konstanta `CHUNK_SIZE` untuk membatasi ukuran batch selama proses data.
- Memperkenalkan properti `$entryBatch` untuk menyimpan batch data sementara sebelum disimpan ke database.
- Mengubah metode `saveRecord` menjadi `addToBatch` untuk menambahkan record ke batch alih-alih langsung menyimpannya.
- Menambahkan metode `saveBatch` untuk menyimpan batch data ke database secara efisien menggunakan `upsert`.
- Menangani chunking dalam proses CSV untuk menghindari masalah memori dengan memproses data dalam batch.
- Menambahkan logging pada setiap batch yang selesai diproses untuk melacak kemajuan pemrosesan.
- Memastikan batch yang tersisa diproses setelah selesai membaca file CSV.
- Menambahkan penanganan error saat menyimpan batch untuk mencegah reprocessing pada batch yang gagal memproses.
- Otomatis menambahkan timestamp (`created_at` dan `updated_at`) pada setiap record sebelum dimasukkan ke batch.
2025-05-28 09:28:12 +07:00
daengdeni
cbfe2c4aa9 feat(webstatement): optimize customer data processing with batch handling
- Tambahkan konstanta `CHUNK_SIZE` untuk memproses data dalam ukuran batch guna mengurangi penggunaan memori.
- Perkenalkan properti baru `customerBatch` untuk menyimpan data sementara sebelum disimpan ke database.
- Ubah metode `processRow` agar menambahkan data ke batch menggunakan metode baru `addToBatch` daripada langsung menyimpannya.
- Tambahkan metode `saveBatch` untuk melakukan penyimpanan batch secara bulk menggunakan `Customer::upsert` dengan pengelolaan kolom unik dan kolom yang perlu diperbarui.
- Tambahkan log untuk setiap chunk yang telah berhasil diproses, membantu memonitor progres saat pemrosesan file CSV dengan ukuran besar.
- Pastikan sisa data yang belum diproses di akhir loop juga disimpan dengan memanggil `saveBatch`.
- Tangani kegagalan penyimpanan batch dengan log error dan reset batch untuk menghindari re-pemrosesan data yang gagal.
- Optimalkan performa dengan menambahkan timestamp (`created_at` dan `updated_at`) secara langsung saat menambahkan data ke batch.
2025-05-28 09:27:49 +07:00
daengdeni
a8dafb23c5 refactor(webstatement): optimize ProcessAccountDataJob logic with batch processing
- Menambahkan properti baru `CHUNK_SIZE` untuk mengelola proses data dalam batch sehingga mengurangi penggunaan memori.
- Memperkenalkan batching untuk penyimpanan akun (`accountBatch`) dan balance (`balanceBatch`) untuk mengurangi beban query database.
- Mengganti penyimpanan langsung di database dengan metode bulk insert menggunakan `upsert` pada tabel Account dan AccountBalance.
- Menambahkan logging untuk mencatat setiap pemrosesan batch dan memberikan deskripsi jumlah chunk yang telah diproses.
- Memindahkan proses normalization dan balance data ke sistem batching alih-alih penyimpanan langsung.
- Menambahkan validasi tambahan untuk pengolahan data row dan mencegah error data yang memiliki kolom tidak sesuai.
- Memperbarui log pesan untuk menampilkan jumlah record yang diproses dan error secara lebih rinci.
- Memodularisasi fungsi agar lebih readable dan maintainable dengan beberapa fungsi baru seperti `addToBatch` dan `saveBatch`.
2025-05-28 08:21:25 +07:00
Daeng Deni Mardaeni
60e60b4fef fix(webstatement): perbaiki typo atribut 'narrativ' menjadi 'narrative'
- Mengubah pengecekan atribut `$item->narrativ` menjadi `$item->narrative` pada beberapa kondisi.
- Memastikan data yang diakses menggunakan properti yang benar (`narrative`) agar sesuai dengan struktur yang diharapkan.
- Menghindari potensi bug atau error akibat typo pada nama properti.
- Memperbaiki kondisi pengecekan pada blok `if` dan `else if` untuk konsistensi penggunaan atribut.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-27 21:57:39 +07:00
daengdeni
4c1163fa05 refactor(export-statement): improve narrative generation logic
- Mengubah tipe variable `$narr` dari string menjadi array untuk mempermudah pengelolaan elemen-elemen narasi.
- Menambahkan elemen narasi ke dalam array menggunakan metode `array_push` alih-alih concatenation dengan string.
- Menggunakan `array_filter` untuk memastikan semua elemen yang bernilai kosong tidak ikut ke dalam hasil akhir.
- Mengganti `trim` dengan `implode` untuk menggabungkan elemen narasi menjadi string dengan separator spasi.
- Memperbaiki fallback logic saat properti transaksional tidak ada, lebih rapi dan terstruktur pada skenario `else if ($item->narrativ)`.
- Penggunaan logika baru memastikan bahwa narasi lebih fleksibel dan tidak menghasilkan karakter-karakter string kosong yang tidak diperlukan di output akhir.
2025-05-27 15:52:24 +07:00
Daeng Deni Mardaeni
13344959c4 fix(Webstatement): inisialisasi default untuk properti $period di semua Job
- Mengatur nilai default properti `$period` menjadi string kosong (`''`) di semua file Job terkait dalam modul `Webstatement`.
- File-file yang terpengaruh:
  1. `ProcessAccountDataJob.php`
  2. `ProcessArrangementDataJob.php`
  3. `ProcessAtmTransactionJob.php`
  4. `ProcessBillDetailDataJob.php`
  5. `ProcessCategoryDataJob.php`
  6. `ProcessCompanyDataJob.php`
  7. `ProcessCustomerDataJob.php`
  8. `ProcessDataCaptureDataJob.php`
  9. `ProcessFtTxnTypeConditionJob.php`
  10. `ProcessFundsTransferDataJob.php`
  11. `ProcessStmtEntryDataJob.php`
  12. `ProcessStmtNarrFormatDataJob.php`
  13. `ProcessStmtNarrParamDataJob.php`
  14. `ProcessTellerDataJob.php`
  15. `ProcessTransactionDataJob.php`
- Perubahan ini bertujuan untuk meningkatkan stabilitas dengan memastikan nilai awal dari properti telah terdefinisi untuk menghindari potensi `undefined property error`.
- Tidak ada perubahan logika lain di luar inisialisasi default nilai properti.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-27 03:58:24 +07:00
Daeng Deni Mardaeni
d6bd84c4e5 Merge remote-tracking branch 'composer/master' 2025-05-26 20:24:26 +07:00
Daeng Deni Mardaeni
4321150d13 refactor(webstatement): pisahkan pengolahan data saldo dari data akun
- Menambahkan properti baru `$balanceData` untuk memisahkan data saldo dari data akun.
- Menyimpan nilai `open_actual_bal` dan `open_cleared_bal` dalam `$balanceData` sebelum menghapusnya dari data akun.
- Mengubah parameter `saveAccountBalance` dari array data akun menjadi hanya `accountNumber`.
- Memanfaatkan `$balanceData` untuk mengatur nilai `actual_balance` dan `cleared_balance` dalam proses penyimpanan ke model `AccountBalance`.
- Mengurangi potensi error dan membuat kode lebih modular dengan memisahkan pengolahan data saldo dari data akun.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 20:23:58 +07:00
daengdeni
299ed0b018 fix(export): improve narrative generation logic and add additional ID formats
- Menambahkan validasi untuk memeriksa apakah `transaction` ada sebelum mengakses propertinya sehingga mencegah error pada situasi `null`.
- Menambahkan fallback description jika `transaction` bernilai null pada metode `generateNarrative`.
- Menambahkan dukungan untuk ID narasi tambahan seperti `APITRX`, `ONUSCR`, dan `ONUSDR` pada `getFormatNarrative`.
- Memodifikasi output `cleanPart` dengan menambahkan spasi untuk memastikan format narasi lebih konsisten.
- Mengubah penggantian `<NL>` dalam hasil akhir menjadi spasi kosong untuk meningkatkan keterbacaan hasil narasi.
2025-05-26 16:47:02 +07:00
Daeng Deni Mardaeni
9025663954 refactor(migrasi): optimalkan import dan reorganisasi method __call
- Refactor import `Jobs` menggunakan sintaks kurung kurawal untuk mengurangi redundansi dan meningkatkan keterbacaan kode.
- Pindahkan metode `__call` di atas `processData` untuk memperbaiki struktur kode sehingga lebih terorganisir.
- Atur ulang logika dalam `__call` tanpa mengubah fungsionalitas utama tetapi memperbaiki posisi metode dalam kelas.
- Hilangkan duplikasi metode `__call` dengan memindahkannya ke lokasi baru.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 15:49:00 +07:00
daengdeni
c6363473ac refactor(jobs): optimize job classes by adding modular methods and constants
- Menambahkan konstanta baru pada setiap job untuk meningkatkan keterbacaan:
  - `CSV_DELIMITER` untuk delimiter CSV.
  - `MAX_EXECUTION_TIME` untuk batas waktu eksekusi (86400 detik).
  - `FILENAME` untuk nama file masing-masing job.
  - `DISK_NAME` untuk disk yang digunakan.
- Mengubah `protected` menjadi `private` untuk properti seperti:
  - `$period`, `$processedCount`, dan `$errorCount` di semua job.
- Memindahkan logika proses menjadi metode modular untuk meningkatkan modularitas:
  - Metode `initializeJob` untuk inisialisasi counter.
  - Metode `processPeriod` untuk menangani file dan memulai proses.
  - Metode `validateFile` untuk validasi keberadaan file.
  - Metode `createTemporaryFile` untuk menyalin file sementara.
  - Metode `processFile` untuk membaca isi file CSV dan memproses.
  - Metode `processRow`/`mapAndSaveRecord`/`saveRecord` untuk pemrosesan dan penyimpanan data.
  - Metode `cleanup` untuk menghapus file sementara.
  - Metode `logJobCompletion` untuk logging hasil akhir.
- Menerapkan pengolahan file dengan sistem logging terperinci:
  - Logging row dengan kolom tidak sesuai akan menghasilkan peringatan.
  - Mencatat jumlah record berhasil diproses serta error.

Refaktor ini bertujuan untuk meningkatkan kualitas kode melalui modularisasi, keterbacaan, dan kemudahan pengujian ulang di seluruh kelas job.
2025-05-26 15:26:43 +07:00
daengdeni
41ed7c1ed9 refactor(jobs): improve flexibility in periods parameter handling
- Mengubah tipe properti `periods` dari `protected $periods` menjadi `protected array $periods` untuk memastikan konsistensi tipe data.
- Memodifikasi konstruktor pada beberapa job class (`ProcessFtTxnTypeConditionJob`, `ProcessStmtNarrFormatDataJob`, `ProcessStmtNarrParamDataJob`, dan `ProcessTransactionDataJob`) agar mendukung parameter `periods` dalam bentuk array atau string.
- Menambahkan validasi dalam konstruktor untuk mengonversi string `periods` menjadi array jika diperlukan, memastikan fleksibilitas dan kompatibilitas yang lebih baik saat inisialisasi object.
- Refaktor ini meningkatkan kejelasan dalam codebase dan mengurangi potensi error akibat perbedaan format input.
2025-05-26 13:47:52 +07:00
daengdeni
1e1120d29b refactor(migrasi): simplify and centralize data processing logic
- Menghapus metode proses data individual untuk setiap tipe data dengan menggantinya menjadi metode `processData`.
- Mengintegrasikan semua tipe proses data ke dalam konstanta `PROCESS_TYPES` untuk mempermudah pengelolaan dan memperluas tipe proses.
- Menambahkan konstanta `PARAMETER_PROCESSES` dan `DATA_PROCESSES` untuk memisahkan proses data parameter dan data utama.
- Mengimplementasikan metode `__call` untuk mendukung pemanggilan metode dinamis berdasarkan tipe proses data.
- Memperbaiki metode `index` untuk mendukung pemrosesan otomatis data parameter dan data utama dalam urutan yang ditentukan.
- Menambahkan logging untuk setiap proses data agar memudahkan debugging dan monitoring.
- Memastikan pengembalian respons JSON seragam untuk keberhasilan dan kegagalan setiap proses data.
2025-05-26 13:40:37 +07:00
Daeng Deni Mardaeni
3cb3eb449b feat(webstatement): perbaikan mapping data dan penambahan format trans referensi
- Memperbaiki mapping `listAccount` di `WebstatementController`:
  - Mengubah urutan key 'OY' dan 'PLUANG' sehingga data ditukar posisinya.
- Menambahkan logika untuk mendukung format baru di `ExportStatementJob`:
  - Menambahkan dukungan format untuk `TTTRFOUT` dengan value 'TT.O.TRF'.
  - Menambahkan dukungan format untuk `TTTRFIN` dengan value 'TT.I.TRF'.
- Menambahkan pengecekan prefix pada `trans_reference` untuk mapping field secara dinamis:
  - Menambahkan mapping prefix baru dengan `relationMap` seperti `FT`, `TT`, `DC`, dan `AA` yang mengarahkan ke relasi data spesifik.
  - Menyesuaikan logika fallback agar memprioritaskan field berdasarkan prefix sebelum default ke data aslinya.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 09:47:21 +07:00
Daeng Deni Mardaeni
6a7a3418b7 fix(webstatement): perbaiki logika kondisi periode kosong di job processing
- Memperbaiki kondisi pengecekan `$this->period` yang salah (ekstra tanda kurung) pada beberapa job processing, seperti:
  1. `ProcessAtmTransactionJob`
  2. `ProcessBillDetailDataJob`
  3. `ProcessCategoryDataJob`
  4. `ProcessCompanyDataJob`
  5. `ProcessCustomerDataJob`
  6. `ProcessDataCaptureDataJob`
  7. `ProcessFundsTransferDataJob`
  8. `ProcessStmtEntryDataJob`
  9. `ProcessTellerDataJob`
- Menambahkan kejelasan logging untuk kasus ketika periode tidak disediakan (`period === ''`).
- Menghapus kode komentar tidak terpakai di `ProcessFtTxnTypeConditionJob`.

Perubahan ini memastikan bahwa logika untuk kondisi periode kosong berjalan dengan benar dan menghindari potensi error di runtime.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 09:12:17 +07:00
Daeng Deni Mardaeni
8abb8f6901 fix(webstatement): perbaikan pengecekan variabel periode dalam processing data
Memperbaiki pengecekan variabel `$this->period` dari `empty()` menjadi `=== ''` pada berbagai job dalam modul Webstatement untuk memastikan logika validasi berfungsi dengan baik dan lebih eksplisit.

- Job yang diperbaiki:
  - `ProcessAccountDataJob`
  - `ProcessArrangementDataJob`
  - `ProcessAtmTransactionJob`
  - `ProcessBillDetailDataJob`
  - `ProcessCategoryDataJob`
  - `ProcessCompanyDataJob`
  - `ProcessCustomerDataJob`
  - `ProcessDataCaptureDataJob`
  - `ProcessFundsTransferDataJob`
  - `ProcessStmtEntryDataJob`
  - `ProcessTellerDataJob`

- Memastikan validasi terhadap variabel `$this->period` hanya memfokuskan pada apakah nilainya adalah string kosong (`''`).
- Menambahkan kejelasan pada logging apabila periode tidak tersedia pada setiap job.
- Perubahan ini bertujuan untuk menghindari potensi false negative pada pengecekan kondisi `empty()` yang dapat menyebabkan logika tidak berjalan sebagaimana mestinya.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 09:05:53 +07:00
Daeng Deni Mardaeni
23611ef79b refactor(webstatement): update job and model for arrangement processing
- Refactor `ProcessArrangementDataJob`:
  - Mengubah parameter dari array periods menjadi string period untuk simplifikasi proses.
  - Mengadaptasi logika proses file CSV dari multiple periods menjadi single period.
  - Menghapus logika iterasi folder `_parameter` dan menyederhanakan nama file dengan menggunakan single period.
  - Menambahkan validasi dan penanganan error jika file tidak ditemukan atau tidak dapat dibuka.
  - Menyederhanakan proses membaca dan memproses row dari file CSV dengan pendekatan baru.
  - Memperbaiki logging untuk mencatat catatan processing dan error yang lebih tepat.

- Update Model `StmtEntry`:
  - Menambahkan relasi baru:
    - `tt`: Relasi dengan model `Teller` berdasarkan `trans_reference`.
    - `dc`: Relasi dengan model `DataCapture` berdasarkan `trans_reference`.
    - `aa`: Relasi dengan model `TempArrangement` berdasarkan `trans_reference`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 08:44:10 +07:00
Daeng Deni Mardaeni
429df7035c fix(webstatement): optimalkan proses pembersihan data dan update balance
Melakukan perbaikan serta optimisasi pada proses pembersihan data dan pengolahan saldo akun.

Perubahan utama:
- **ProcessStmtEntryDataJob**:
    - Mengganti `str_replace` dengan `preg_replace` untuk pembersihan `trans_reference`, sehingga lebih fleksibel dalam menghapus karakter setelah `\` (termasuk keseluruhan pattern yang lebih kompleks).
    - Mengevaluasi penghapusan potensi substring logic yang tidak digunakan, memperkuat pembersihan menjadi lebih konsisten.
- **ProcessAccountDataJob**:
    - Mengganti penggunaan `firstOrNew` dan `save` dengan `updateOrInsert` untuk mengurangi jumlah query ke database.
    - Menambahkan pembuatan data `created_at` dan `updated_at` untuk memastikan data yang di-update memiliki timestamp konsisten.
    - Menjamin default value pada `actual_balance` dan `cleared_balance` apabila data masukan kosong agar sistem tetap dapat berjalan tanpa error.

Perubahan ini dilakukan untuk meningkatkan efisiensi proses pengolahan data, mengurangi overhead query, serta memastikan data yang diproses tetap konsisten dan lebih aman.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-26 08:28:28 +07:00
Daeng Deni Mardaeni
e531193c06 Merge remote-tracking branch 'composer/master' 2025-05-24 19:44:32 +07:00
Daeng Deni Mardaeni
38987ce8e3 refactor(webstatement): remove _parameter folder skipping logic from processing jobs
- Menghapus logika pengecekan dan pengabaian folder `_parameter` di seluruh job pemrosesan data berikut:
  - `ProcessAccountDataJob`
  - `ProcessAtmTransactionJob`
  - `ProcessBillDetailDataJob`
  - `ProcessCategoryDataJob`
  - `ProcessCompanyDataJob`
  - `ProcessCustomerDataJob`
  - `ProcessDataCaptureDataJob`
  - `ProcessFundsTransferDataJob`
  - `ProcessStmtEntryDataJob`
  - `ProcessTellerDataJob`
- Menghapus konstanta `PARAMETER_FOLDER` yang terkait dengan folder `_parameter` pada beberapa job.
- Membersihkan code redundancy yang tidak relevan untuk meningkatkan keterbacaan dan efisiensi.
- Logika ini dianggap tidak diperlukan lagi dalam proses pemrosesan data.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 19:43:54 +07:00
Daeng Deni Mardaeni
cd447eb019 refactor(jobs): simplify jobs and controllers by replacing period array with single period parameter
- Mengganti parameter `$periods` (array) menjadi `$period` (string) pada semua Job terkait: `ProcessCustomerDataJob`, `ProcessFundsTransferDataJob, etc`.
- Menyederhanakan operasi loop dalam proses data dengan hanya memproses satu periode per eksekusi Job.
- Memodifikasi fungsi controller di `MigrasiController` agar sesuai dengan perubahan parameter dari array ke string.
- Menambahkan pengamanan jika `$period` kosong atau bernilai '_parameter' untuk mencegah proses yang tidak diperlukan.
- Mengurangi duplikasi kode dengan mengeliminasi metode yang mengelola array periode dan menggantinya dengan pendekatan tunggal.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 19:40:40 +07:00
Daeng Deni Mardaeni
85b8bfa07b fix(webstatement): perbaikan parameter dan refactor pada ProcessCompanyDataJob
- Mengubah parameter pada pemanggilan `$this->ProcessCompanyData()` dari array menjadi string untuk konsistensi data.
- Mengubah properti `protected` pada `ProcessCompanyDataJob` dari `$periods` menjadi `$period` untuk menggunakan string daripada array.
- Menyesuaikan constructor `__construct` untuk menerima parameter string `$period` alih-alih array `$periods`.
- Memperbaiki mekanisme validasi pada `handle()`, mengganti pengecekan array kosong `$this->periods` menjadi validasi string `$this->period` dengan nilai kosong.
- Menghapus iterasi `foreach` untuk mengakomodasi perubahan dari array ke string sederhana.
- Memastikan mapping data CSV tetap konsisten dan menambahkan identasi untuk peningkatan keterbacaan.
- Memperbaiki nama variabel dan properti agar lebih eksplisit (`$periods` menjadi `$period`, `$fileName` menjadi `$fileName`, dsb.).
- Menambahkan logging yang lebih jelas mengenai proses data dan kondisi error pada job `ProcessCompanyDataJob`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 19:22:30 +07:00
Daeng Deni Mardaeni
bf7206f927 refactor(webstatement): ubah parameter periode dari array menjadi string
- Mengubah parameter pada metode `ProcessCategoryData` di `MigrasiController` dari array menjadi string untuk keseragaman dengan metode lainnya.
- Memperbarui konstruksi parameter pada instansi `ProcessCategoryDataJob` untuk menerima tipe data string sebagai pengganti array.
- Menghilangkan iterasi array `periods` pada `ProcessCategoryDataJob` dan menerapkan logika langsung pada single `period`.
- Menyesuaikan validasi periode untuk mengabaikan folder `_parameter` dalam proses.
- Memperlihatkan log lebih spesifik jika file tidak ditemukan, atau format kolom tidak sesuai ekspektasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 19:19:33 +07:00
daengdeni
562cc94822 refactor(database): ubah tipe kolom dari date/datetime/decimal menjadi string di berbagai tabel
Ubah tipe kolom di database yang sebelumnya menggunakan date, datetime, atau decimal menjadi string. Langkah ini meningkatkan fleksibilitas penyimpanan data.

- `customers` table:
  - Ubah tipe kolom `date_of_birth` dari `date` menjadi `string`.

- `accounts` table:
  - Ubah tipe kolom `opening_date` dari `date` menjadi `string`.
  - Ubah tipe kolom `closure_date` dari `date` menjadi `string`.
  - Revisi kolom `start_year_bal` menjadi `string` dengan parameter yang disesuaikan.

- `ft_txn_type_condition` table:
  - Ubah tipe kolom `date_time` dari `dateTime` menjadi `string`.

- `data_captures` table:
  - Ubah tipe kolom `value_date`, `exposure_date`, dan `accounting_date` dari `date` menjadi `string`.
  - Ubah tipe kolom `date_time` dari `dateTime` menjadi `string`.
  - Ubah tipe kolom `amount_lcy`, `amount_fcy`, dan `exchange_rate` dari `decimal` menjadi `string`.

- `temp_arrangements` table:
  - Ubah tipe kolom `orig_contract_date` dan `start_date` dari `date` menjadi `string`.

- Model changes:
  - Hapus properti `casts` dari model berikut:
    - `AtmTransaction`
    - `DataCapture`
    - `FtTxnTypeCondition`
2025-05-24 17:04:31 +07:00
Daeng Deni Mardaeni
b894a2c9c4 feat(webstatement): tambahkan fitur schedule dan console command untuk migrasi dan ekspor data harian
- Menambahkan dua console command baru:
  1. `webstatement:process-daily-migration` untuk memproses migrasi data harian.
  2. `webstatement:export-statements` untuk mengekspor laporan harian.

- Mendefinisikan command `webstatement:process-daily-migration`:
  - Menggunakan `MigrasiController` untuk memproses data migrasi.
  - Menangkap error selama proses migrasi dan memberikan output informasi status.

- Mendefinisikan command `webstatement:export-statements`:
  - Menggunakan `WebstatementController` untuk memproses ekspor laporan harian.
  - Memberikan informasi terkait jumlah job ekspor yang berhasil di-queue dan menangkap error selama proses.

- Menambahkan schedule untuk kedua command:
  1. `webstatement:process-daily-migration` dijalankan setiap hari pukul 09:00 dan log aktivitas disimpan di `daily-migration.log`.
  2. `webstatement:export-statements` dijalankan setiap hari pukul 09:30 dan log aktivitas disimpan di `statement-export.log`.

- Melengkapi `WebstatementServiceProvider` dengan pendaftaran command dan konfigurasi jadwal baru.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 16:12:33 +07:00
Daeng Deni Mardaeni
29ed72ad8b feat(webstatement): tambahkan atribut client untuk pemisahan data per klien
- Mengubah struktur listAccount menjadi array multidimensional untuk mendukung penamaan klien.
- Menambahkan parameter `client_name` pada data yang dikirimkan ke job ExportStatementJob.
- Memperbaiki penulisan nama file export dengan menambahkan informasi nama klien.
- Mengimplementasikan pembuatan direktori berdasarkan nama klien dan nomor akun untuk pengelompokan file secara terstruktur.
- Menambahkan atribut baru `client` pada ExportStatementJob untuk mengelola data terkait klien secara lebih spesifik.
- Melakukan perubahan pada proses export CSV:
  - Menentukan struktur direktori berdasarkan klien dan akun.
  - Menambahkan langkah untuk membuat direktori klien dan akun jika belum ada.
  - Menyesuaikan log informasi dan path untuk setiap file yang di-export.
- Perubahan ini bertujuan untuk mempermudah pengelolaan statement per klien dengan struktur file yang lebih terorganisir.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 14:55:36 +07:00
Daeng Deni Mardaeni
d6915aef1c fix(migrasi): perbaiki pesan respons untuk pengecekan folder dan proses data
- Memperbarui pesan respons ketika folder periode tidak ditemukan di penyimpanan SFTP agar menyertakan informasi periode secara dinamis.
- Memperbaiki pesan respons ketika pekerjaan pemrosesan data berhasil untuk menyertakan informasi periode yang diproses.
- Meningkatkan kejelasan informasi dalam respon JSON untuk kebutuhan debugging dan pelacakan yang lebih baik.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 14:33:32 +07:00
Daeng Deni Mardaeni
d1962113ed feat(webstatement): update migrasi workflow and optimize period handling
- Menambahkan metode `index_manual` pada `MigrasiController` untuk pemrosesan manual.
- Mengganti implementasi metode `index` di `MigrasiController` agar secara otomatis menentukan dan memproses data dengan periode hari sebelumnya.
- Menambahkan validasi untuk memeriksa keberadaan folder periode pada storage SFTP sebelum melakukan pemrosesan.
- Menyesuaikan logika pemanggilan fungsi-fungsi pemrosesan data sesuai urutan:
  - `ProcessCategoryData`
  - `ProcessCompanyData`
  - `processCustomerData`
  - `processAccountData`
  - `processStmtEntryData`
  - `ProcessDataCaptureData`
  - `processFundsTransferData`
  - `ProcessTellerData`
  - `ProcessAtmTransaction`
  - `processArrangementData`
  - `processBillDetailData`
- Memodifikasi daftar periode pada metode `listPeriod` di `WebstatementController` untuk secara dinamis mengambil periode hari sebelumnya menggunakan `date('Ymd', strtotime('-1 day'))`.
- Menghapus elemen hardcoded pada daftar periode untuk efisiensi kustomisasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-24 13:54:49 +07:00
daengdeni
566dd1e4e7 refactor(database): update primary key in processed_statements table
- Menghapus kolom `id` dari tabel `processed_statements` dan sekaligus menghapus primary key auto-increment terkait.
- Menetapkan kombinasi kolom `account_number`, `period`, dan `sequence_no` sebagai keys baru dengan composite primary key untuk memastikan setiap record bersifat unik.
- Menambahkan kemampuan rollback pada migration untuk mengembalikan struktur ke kondisi awal:
  - Menghapus composite primary key pada kolom `account_number`, `period`, dan `sequence_no`.
  - Menambahkan kembali kolom `id` sebagai auto-increment primary key di posisi paling awal tabel.
2025-05-24 09:04:15 +07:00
daengdeni
a3f2244fee refactor(webstatement): simplify and enhance statement export process
- Menghapus logika dan kode terkait pengunduhan, pembuatan, dan pengelolaan file statement yang berlebihan.
- Menambahkan fungsi `listAccount` untuk mendapatkan daftar nomor akun secara langsung.
- Menambahkan fungsi `listPeriod` untuk mengatur daftar periode yang digunakan dalam proses.
- Menambahkan fungsi `getAccountBalance` untuk mengakses saldo akun berdasarkan nomor akun dan periode.
- Mengubah metode `index` menjadi lebih modular, menggunakan fungsi tambahan untuk menyederhanakan pembuatan data dan queue statement.
- Menyederhanakan tanggapan JSON untuk pesan keberhasilan pengajuan job export.
- Mengimpor model `AccountBalance` yang diperlukan untuk fungsi baru.
2025-05-24 08:59:20 +07:00
daengdeni
2e2c8b4b0d refactor(database): remove id from account_balances table
- Menghapus kolom `id` dari tabel `account_balances` termasuk primary key auto-increment-nya.
- Menjadikan kolom `account_number` dan `period` sebagai composite primary key.
- Menghapus constraint unik pada kolom `account_number` dan `period`.
- Menambahkan kembali kolom `id` dan constraint unik pada proses rollback.
- Memastikan mendukung migrasi maju dan mundur dengan aman.
2025-05-24 08:27:35 +07:00
daengdeni
e3b6e46d83 ```
refactor(jobs): update file extension and sanitize transaction reference

- Mengubah konstanta `FILE_EXTENSION` pada `ProcessAtmTransactionJob` dari `.ST.ATM.csv` menjadi `.ST.ATM.TRANSACTION.csv`.
- Menambahkan logika pembersihan pada `trans_reference` dalam `ProcessStmtEntryDataJob`:
  - Menghapus string `\BNK` jika ada dalam field `trans_reference`.
- Mempertahankan fungsionalitas utama untuk memastikan kompatibilitas data dan pengolahan job tetap berjalan sesuai dengan kebutuhan.
```
2025-05-24 08:27:03 +07:00
Daeng Deni Mardaeni
a687385017 fix(migration): ubah tipe data balance menjadi string
- Mengubah tipe data kolom `actual_balance` dan `cleared_balance` dari `decimal` menjadi `string`.
- Menambahkan default value `0` pada kedua kolom tersebut.
- Perubahan untuk memastikan kompatibilitas dalam pengelolaan data balance dengan format non-numerik atau string.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 21:50:01 +07:00
Daeng Deni Mardaeni
3414fd9414 fix(webstatement): optimize balance assignment in ProcessAccountDataJob
- Mengubah logika assignment nilai balance untuk memanfaatkan operator null coalescing (??).
- Menghapus pengecekan eksplisit untuk `isset` pada `open_actual_bal` dan `open_cleared_bal`.
- Menambahkan default value `0` jika data balance tidak tersedia.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 21:42:46 +07:00
Daeng Deni Mardaeni
cb0a248ce5 refactor(webstatement): improve code readability and consistency in ExportStatementJob
- Menambahkan tipe return untuk beberapa method agar lebih eksplisit dan konsisten.
- Memperbaiki indentasi dan alignment parameter untuk meningkatkan keterbacaan.
- Menghapus method `getTransaction` dan memindahkannya ke bagian akhir kode dengan dokumentasi ulang.
- Menambahkan logika untuk menghapus file CSV yang sudah ada sebelum membuat file baru di method `exportToCsv`.
- Mengoreksi format data dan whitespace pada beberapa bagian kode untuk menjaga standar penulisan.
- Memindahkan komentar terkait dokumentasi method ke posisi yang lebih relevan dan terstruktur.
- Memastikan konsistensi penggunaan tanda kurung kurawal dan spasi pada query database agar lebih seragam.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 21:30:04 +07:00
Daeng Deni Mardaeni
57463f2429 fix(webstatement): optimasi proses pengelolaan statement dan perbaikan logika pemrosesan data
- Menghilangkan pengecekan data yang sebelumnya diproses dengan memindahkan logika perbandingan jumlah data ke dalam fungsi `processStatementData`.
- Menambahkan beberapa fungsi baru untuk memisahkan logika ke dalam unit yang lebih kecil:
  1. `getTotalEntryCount`: Menghitung total jumlah data berdasarkan kriteria akun dan periode.
  2. `getExistingProcessedCount`: Menghitung jumlah data yang sudah diproses.
  3. `deleteExistingProcessedData`: Menghapus data hasil proses sebelumnya jika ada ketidaksesuaian jumlah.
  4. `processAndSaveStatementEntries`: Memproses dan menyimpan data dalam batch untuk efisiensi memori.
  5. `prepareProcessedData`: Menyiapkan array data hasil pemrosesan sebelum disimpan ke database.
  6. `formatTransactionDate`: Memformat tanggal transaksi dengan logika fallback pada error parsing.
  7. `formatActualDate`: Memformat tanggal aktual dengan fallback dan logging untuk error parsing.
- Memperbaiki logika pemrosesan data statement:
  - Menambahkan validasi jumlah data yang diproses untuk menghindari duplikasi atau penghapusan data yang tidak semestinya.
  - Menghapus semua data hasil proses sebelumnya untuk satu kombinasi akun dan periode hanya jika terjadi ketidaksesuaian jumlah data.
- Menggunakan chunk batch untuk memproses data dengan lebih efisien, mengurangi penggunaan memori, dan meningkatkan kestabilan aplikasi.
- Menyempurnakan logging untuk memberikan informasi lebih rinci terkait proses pemrosesan data dan mengantisipasi error.
- Mengubah format data waktu pada proses narasi dengan fallback yang lebih aman guna mencegah kegagalan parsing.
- Menambahkan penghapusan data lama sebelum proses penyimpanan ulang guna memastikan konsistensi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 21:28:39 +07:00
Daeng Deni Mardaeni
9f0ee812a9 fix(webstatement): perbaikan format narasi dan penghilangan karakter tidak diinginkan
- Menambahkan pemanggilan fungsi `trim()` untuk menghapus spasi berlebihan di akhir narasi pada return value di fungsi pertama.
- Memperbaiki kondisi pengecekan `!==` pada variable `$fieldName` untuk meningkatkan kejelasan dan konsistensi dalam penulisan.
- Mengubah logika pengisian hasil narasi dengan memanfaatkan shorthand `$item->ft?->$fieldName ?? ''` untuk menghindari error saat field tidak ada nilainya.
- Menambahkan fungsi `str_replace` untuk menghapus karakter `<NL>` yang tidak diinginkan dari hasil narasi pada return value di fungsi kedua.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 20:44:38 +07:00
Daeng Deni Mardaeni
ed4ffb4254 feat(migrasi): aktifkan kembali pemrosesan data untuk berbagai periode
- Mengubah daftar periode yang diproses dengan mengaktifkan subset baru ('20250520', '20250521', '20250522') dan menonaktifkan subset lama dengan kondisi komentar.
- Mengaktifkan kembali berbagai metode pemrosesan data:
  - `ProcessCategoryData` dan `ProcessCompanyData` diaktifkan untuk setiap periode.
  - `processCustomerData` serta `processAccountData` mendapatkan aktifasi penuh.
  - Pemrosesan detail: `processStmtEntryData`, `ProcessDataCaptureData`, `processFundsTransferData`, `ProcessTellerData`, dan `ProcessAtmTransaction` juga diaktifkan.
- Metode `processArrangementData` dan `processBillDetailData` juga dimasukkan kembali dalam workflow pemrosesan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 20:20:17 +07:00
Daeng Deni Mardaeni
ac6d139b4a refactor(migrasi): ubah logika pengolahan folder periode dan data di SFTP
- Nonaktifkan penggunaan SFTP disk untuk mendapatkan daftar folder periode.
- Gantikan `allDirectories` dengan daftar hardcoded periode langsung di kode.
- Tambahkan daftar periode berupa array statis untuk menggantikan logika filter otomatis.
- Ubah proses iterasi data:
  - Nonaktifkan pemanggilan metode `ProcessCategoryData` dan `ProcessCompanyData`.
  - Aktifkan pemanggilan metode `processAccountData` untuk setiap periode.
  - Nonaktifkan proses data lainnya seperti `processCustomerData`, `ProcessTellerData`, dan `ProcessAtmTransaction`.
- Pastikan response JSON tetap konsisten dengan hasil dan status HTTP.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 19:57:39 +07:00
Daeng Deni Mardaeni
59d186e3b5 feat(webstatement): simpan saldo pembukaan ke model AccountBalance
- Tambahkan use statement untuk model `AccountBalance` di `ProcessAccountDataJob`.
- Simpan saldo pembukaan (`open_actual_bal` dan `open_cleared_bal`) dari data akun yang diproses ke model `AccountBalance`.
- Gunakan `firstOrNew` untuk memastikan data saldo pembukaan unik berdasarkan nomor akun dan periode tertentu.
- Tambahkan log untuk mencatat penyimpanan saldo pembukaan yang berhasil dilakukan.
- Pastikan penyimpanan hanya dilakukan jika data saldo tersedia di input (`isset` pada `open_actual_bal` atau `

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 19:34:39 +07:00
Daeng Deni Mardaeni
a7a55a92a1 feat(webstatement): tambah model AccountBalance dan relasi balances pada Account
- Menambahkan model `AccountBalance` dengan fitur berikut:
  - Properti `fillable` meliputi: `account_number`, `period`, `actual_balance`, `cleared_balance`.
  - Relasi `belongsTo` dengan model `Account`.
  - Scope query untuk filter berdasarkan `account_number` (`scopeForAccount`) dan `period` (`scopeForPeriod`).
  - Fungsi statis `getBalance` untuk mendapatkan saldo berdasarkan `account_number` dan `period`.

- Menambahkan method berikut pada model `Account`:
  - Relasi `hasMany` dengan `AccountBalance` untuk mendapatkan semua saldo terkait.
  - Method `getBalanceForPeriod` untuk mendapatkan saldo pada periode tertentu.

- Membuat migrasi untuk tabel `account_balances` dengan spesifikasi berikut:
  - Kolom: `account_number`, `period` (format: YYYY-MM), `actual_balance` (decimal), `cleared_balance` (decimal), `timestamps`.
  - Konstrain unik untuk pasangan `account_number` dan `period`.
  - Indeks pada kolom `account_number`, `period`, dan `created_at`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-05-23 19:27:44 +07:00
87 changed files with 11150 additions and 2718 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

@@ -0,0 +1,48 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\CombinePdfController;
class CombinePdf extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:combine-pdf {--period= : Period to process migration format Ym contoh. 202506}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process combine pdf';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Starting combine pdf process...');
$period = $this->option('period');
try {
$controller = app(CombinePdfController::class);
$response = $controller->combinePdfs($period);
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message'] ?? 'Process completed');
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error processing combine pdf: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Modules\Webstatement\Console;
use Illuminate\Console\Command;
use Modules\Webstatement\Jobs\ConvertHtmlToPdfJob;
use Exception;
class ConvertHtmlToPdf extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:convert-html-to-pdf {directory}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Convert HTML files to PDF in the specified directory';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
try {
$directory = $this->argument('directory');
$this->info('Starting HTML to PDF conversion process...');
// Dispatch the job
ConvertHtmlToPdfJob::dispatch($directory);
$this->info('HTML to PDF conversion job has been queued.');
return 0;
} catch (Exception $e) {
$this->error('Error processing HTML to PDF conversion: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\WebstatementController;
class ExportDailyStatements extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:export-statements';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export daily statements for all configured client accounts';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Starting daily statement export process...');
try {
$controller = app(WebstatementController::class);
$response = $controller->index();
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message']);
// Display summary of jobs queued
$jobCount = count($responseData['jobs'] ?? []);
$this->info("Successfully queued {$jobCount} statement export jobs");
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error exporting statements: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\WebstatementController;
class ExportPeriodStatements extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:export-period-statements
{--account_number= : Account number to process migration}
{--period= : Period to process migration format Ym contoh. 202506}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export period statements for all configured client accounts';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$accountNumber = $this->option('account_number');
$period = $this->option('period');
$this->info('Starting period statement export process...');
try {
$controller = app(WebstatementController::class);
$response = $controller->printStatementRekening($accountNumber, $period);
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message']);
// Display summary of jobs queued
$jobCount = count($responseData['jobs'] ?? []);
$this->info("Successfully queued {$accountNumber} statement export jobs");
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error exporting statements: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

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,51 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\MigrasiController;
class ProcessDailyMigration extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:process-daily-migration
{--process_parameter= : To process migration parameter true/false}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process data migration for the previous day\'s period';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$processParameter = $this->option('process_parameter');
$this->info('Starting daily data migration process...');
$this->info('Process Parameter: ' . ($processParameter ?? 'False'));
try {
$controller = app(MigrasiController::class);
$response = $controller->index($processParameter);
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message'] ?? 'Process completed');
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error processing daily migration: ' . $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}");
}
}

49
app/Console/UnlockPdf.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
namespace Modules\Webstatement\Console;
use Illuminate\Console\Command;
use Modules\Webstatement\Jobs\UnlockPdfJob;
use Exception;
class UnlockPdf extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:unlock-pdf {directory} {--password=123456 : Password for PDF files}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Unlock password-protected PDF files in the specified directory';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
try {
$directory = $this->argument('directory');
$password = $this->option('password');
$this->info('Starting PDF unlock process...');
// Dispatch the job
UnlockPdfJob::dispatch($directory, $password);
$this->info('PDF unlock job has been queued.');
return 0;
} catch (Exception $e) {
$this->error('Error processing PDF unlock: ' . $e->getMessage());
return 1;
}
}
}

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

@@ -0,0 +1,215 @@
<?php
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
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('webstatement::index');
}
/**
* Combine PDF files from r14 and r23 folders for all accounts
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
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 with customer relation
$accounts = Account::where('branch_code','ID0010052')->get();
$processedCount = 0;
$skippedCount = 0;
$errorCount = 0;
foreach ($accounts as $account) {
$branchCode = $account->branch_code;
$accountNumber = $account->account_number;
// Define file paths
$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 r14 file exists locally
$r14Exists = File::exists($r14Path);
// 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}");
$skippedCount++;
continue;
}
// Prepare file list for processing
$pdfFiles = [];
if ($r14Exists) {
$pdfFiles[] = $r14Path;
}
if ($r23Exists) {
// Add all r23 files to the list
$pdfFiles = array_merge($pdfFiles, $r23Files);
}
try {
// 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, $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 (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,358 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempStmtNarrFormat;
use Modules\Webstatement\Models\TempStmtNarrParam;
class DebugStatementController extends Controller
{
/**
* Debug a single statement entry
*/
public function debugStatement(Request $request)
{
$request->validate([
'account_number' => 'required|string',
'trans_reference' => 'required|string',
'period' => 'nullable|string'
]);
try {
// Find the statement entry
$query = StmtEntry::with(['ft', 'transaction'])
->where('account_number', $request->account_number)
->where('trans_reference', $request->trans_reference);
if ($request->period) {
$query->where('booking_date', $request->period);
}
$item = $query->first();
if (!$item) {
return response()->json([
'error' => 'Statement entry not found',
'criteria' => [
'account_number' => $request->account_number,
'trans_reference' => $request->trans_reference,
'period' => $request->period
]
], 404);
}
// Generate narrative using the same method from ExportStatementJob
$narrative = $this->generateNarrative($item);
// Format dates
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
return response()->json([
'statement_entry' => [
'account_number' => $item->account_number,
'trans_reference' => $item->trans_reference,
'booking_date' => $item->booking_date,
'amount_lcy' => $item->amount_lcy,
'narrative' => $item->narrative,
'date_time' => $item->date_time
],
'generated_narrative' => $narrative,
'formatted_dates' => [
'transaction_date' => $transactionDate,
'actual_date' => $actualDate
],
'related_data' => [
'ft' => $item->ft,
'transaction' => $item->transaction
],
'debug_info' => $this->getDebugInfo($item)
]);
} catch (Exception $e) {
Log::error('Debug statement error: ' . $e->getMessage());
return response()->json([
'error' => 'An error occurred while debugging the statement',
'message' => $e->getMessage()
], 500);
}
}
/**
* List available statement entries for debugging
*/
public function listStatements(Request $request)
{
$request->validate([
'account_number' => 'required|string',
'period' => 'nullable|string',
'limit' => 'nullable|integer|min:1|max:100'
]);
$query = StmtEntry::where('account_number', $request->account_number);
if ($request->period) {
$query->where('booking_date', $request->period);
}
$statements = $query->orderBy('date_time', 'desc')
->limit($request->limit ?? 20)
->get(['account_number', 'trans_reference', 'booking_date', 'amount_lcy', 'narrative', 'date_time']);
return response()->json([
'statements' => $statements,
'count' => $statements->count()
]);
}
/**
* Generate narrative for a statement entry (copied from ExportStatementJob)
*/
private function generateNarrative($item)
{
$narr = [];
if ($item->transaction) {
if ($item->transaction->stmt_narr) {
$narr[] = $item->transaction->stmt_narr;
}
if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->transaction->narr_type) {
$narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item);
}
} else if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->ft?->recipt_no) {
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
}
return implode(' ', array_filter($narr));
}
/**
* Get formatted narrative based on narrative type (copied from ExportStatementJob)
*/
private function getFormatNarrative($narr, $item)
{
$narrParam = TempStmtNarrParam::where('_id', $narr)->first();
if (!$narrParam) {
return '';
}
$fmt = '';
if ($narrParam->_id == 'FTIN') {
$fmt = 'FT.IN';
} else if ($narrParam->_id == 'FTOUT') {
$fmt = 'FT.OUT';
} else if ($narrParam->_id == 'TTTRFOUT') {
$fmt = 'TT.O.TRF';
} else if ($narrParam->_id == 'TTTRFIN') {
$fmt = 'TT.I.TRF';
} else if ($narrParam->_id == 'APITRX'){
$fmt = 'API.TSEL';
} else if ($narrParam->_id == 'ONUSCR'){
$fmt = 'ONUS.CR';
} else if ($narrParam->_id == 'ONUSDR'){
$fmt = 'ONUS.DR';
}else {
$fmt = $narrParam->_id;
}
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
if (!$narrFormat) {
return '';
}
// Get the format string from the database
$formatString = $narrFormat->text_data ?? '';
// Parse the format string
// Split by the separator ']'
$parts = explode(']', $formatString);
$result = '';
foreach ($parts as $index => $part) {
if (empty($part)) {
continue;
}
if ($index === 0) {
// For the first part, take only what's before the '!'
$splitPart = explode('!', $part);
if (count($splitPart) > 0) {
// Remove quotes, backslashes, and other escape characters
$cleanPart = trim($splitPart[0]).' ';
// Remove quotes at the beginning and end
$cleanPart = preg_replace('/^["\'\\\\\\\]+|["\'\\\\\\\]+$/', '', $cleanPart);
// Remove any remaining backslashes
$cleanPart = str_replace('\\', '', $cleanPart);
// Remove any remaining quotes
$cleanPart = str_replace('"', '', $cleanPart);
$result .= $cleanPart;
}
} else {
// For other parts, these are field placeholders
$fieldName = strtolower(str_replace('.', '_', $part));
// Get the corresponding parameter value from narrParam
$paramValue = null;
// Check if the field exists as a property in narrParam
if (property_exists($narrParam, $fieldName)) {
$paramValue = $narrParam->$fieldName;
} else if (isset($narrParam->$fieldName)) {
$paramValue = $narrParam->$fieldName;
}
// If we found a value, add it to the result
if ($paramValue !== null) {
$result .= $paramValue;
} else {
// If no value found, try to use the original field name as a fallback
if ($fieldName !== 'recipt_no') {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$result .= ($item->$relation?->$fieldName ?? '') . ' ';
}
}
}
}
}
return str_replace('<NL>', ' ', $result);
}
/**
* Format transaction date (copied from ExportStatementJob)
*/
private function formatTransactionDate($item)
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'YmdHi',
$item->booking_date . substr($datetime, 6, 4)
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting transaction date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
/**
* Format actual date (copied from ExportStatementJob)
*/
private function formatActualDate($item)
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'ymdHi',
$datetime
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting actual date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
/**
* Get debug information about the statement entry
*/
private function getDebugInfo($item)
{
$prefix = substr($item->trans_reference ?? '', 0, 2);
$debugInfo = [
'transaction_prefix' => $prefix,
'has_transaction' => !is_null($item->transaction),
'has_ft' => !is_null($item->ft),
'narrative_components' => []
];
if ($item->transaction) {
$debugInfo['transaction_data'] = [
'stmt_narr' => $item->transaction->stmt_narr,
'narr_type' => $item->transaction->narr_type
];
if ($item->transaction->narr_type) {
$narrParam = TempStmtNarrParam::where('_id', $item->transaction->narr_type)->first();
$debugInfo['narr_param'] = $narrParam;
if ($narrParam) {
$fmt = $this->getNarrativeFormat($narrParam->_id);
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
$debugInfo['narr_format'] = $narrFormat;
}
}
}
return $debugInfo;
}
/**
* Get narrative format mapping
*/
private function getNarrativeFormat($narrId)
{
$mapping = [
'FTIN' => 'FT.IN',
'FTOUT' => 'FT.OUT',
'TTTRFOUT' => 'TT.O.TRF',
'TTTRFIN' => 'TT.I.TRF',
'APITRX' => 'API.TSEL',
'ONUSCR' => 'ONUS.CR',
'ONUSDR' => 'ONUS.DR'
];
return $mapping[$narrId] ?? $narrId;
}
}

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

@@ -1,220 +1,123 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use BadMethodCallException;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Log;
use Modules\Webstatement\Jobs\{ProcessAccountDataJob,
ProcessArrangementDataJob,
ProcessAtmTransactionJob,
ProcessBillDetailDataJob,
ProcessCategoryDataJob,
ProcessCompanyDataJob,
ProcessCustomerDataJob,
ProcessDataCaptureDataJob,
ProcessFtTxnTypeConditionJob,
ProcessFundsTransferDataJob,
ProcessStmtEntryDataJob,
ProcessStmtNarrFormatDataJob,
ProcessStmtNarrParamDataJob,
ProcessTellerDataJob,
ProcessTransactionDataJob,
ProcessSectorDataJob};
use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Support\Facades\Storage;
use Log;
use Modules\Webstatement\Jobs\ProcessAccountDataJob;
use Modules\Webstatement\Jobs\ProcessArrangementDataJob;
use Modules\Webstatement\Jobs\ProcessAtmTransactionJob;
use Modules\Webstatement\Jobs\ProcessBillDetailDataJob;
use Modules\Webstatement\Jobs\ProcessCategoryDataJob;
use Modules\Webstatement\Jobs\ProcessCompanyDataJob;
use Modules\Webstatement\Jobs\ProcessCustomerDataJob;
use Modules\Webstatement\Jobs\ProcessDataCaptureDataJob;
use Modules\Webstatement\Jobs\ProcessFtTxnTypeConditionJob;
use Modules\Webstatement\Jobs\ProcessFundsTransferDataJob;
use Modules\Webstatement\Jobs\ProcessStmtEntryDataJob;
use Modules\Webstatement\Jobs\ProcessStmtNarrFormatDataJob;
use Modules\Webstatement\Jobs\ProcessStmtNarrParamDataJob;
use Modules\Webstatement\Jobs\ProcessTellerDataJob;
use Modules\Webstatement\Jobs\ProcessTransactionDataJob;
class MigrasiController extends Controller
{
public function processArrangementData($periods)
class MigrasiController extends Controller
{
try {
ProcessArrangementDataJob::dispatch($periods);
return response()->json(['message' => 'Data Arrangement processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
private const PROCESS_TYPES = [
'transaction' => ProcessTransactionDataJob::class,
'stmtNarrParam' => ProcessStmtNarrParamDataJob::class,
'stmtNarrFormat' => ProcessStmtNarrFormatDataJob::class,
'ftTxnTypeCondition' => ProcessFtTxnTypeConditionJob::class,
'category' => ProcessCategoryDataJob::class,
'company' => ProcessCompanyDataJob::class,
'customer' => ProcessCustomerDataJob::class,
'account' => ProcessAccountDataJob::class,
'stmtEntry' => ProcessStmtEntryDataJob::class,
'dataCapture' => ProcessDataCaptureDataJob::class,
'fundsTransfer' => ProcessFundsTransferDataJob::class,
'teller' => ProcessTellerDataJob::class,
'atmTransaction' => ProcessAtmTransactionJob::class,
'arrangement' => ProcessArrangementDataJob::class,
'billDetail' => ProcessBillDetailDataJob::class,
'sector' => ProcessSectorDataJob::class
];
private const PARAMETER_PROCESSES = [
'transaction',
'stmtNarrParam',
'stmtNarrFormat',
'ftTxnTypeCondition',
'sector'
];
private const DATA_PROCESSES = [
'category',
'company',
'customer',
'account',
'stmtEntry',
'dataCapture',
'fundsTransfer',
'teller',
'atmTransaction',
'arrangement',
'billDetail'
];
public function __call($method, $parameters)
{
if (strpos($method, 'process') === 0) {
$type = lcfirst(substr($method, 7));
if (isset(self::PROCESS_TYPES[$type])) {
return $this->processData($type, $parameters[0] ?? '');
}
}
throw new BadMethodCallException("Method {$method} does not exist.");
}
}
public function processCustomerData($periods)
{
try {
// Pass the periods to the job for processing
ProcessCustomerDataJob::dispatch($periods);
private function processData(string $type, string $period)
: JsonResponse
{
try {
$jobClass = self::PROCESS_TYPES[$type];
$jobClass::dispatch($period);
$message = sprintf('%s data processing job has been queued successfully', ucfirst($type));
Log::info($message);
return response()->json(['message' => $message]);
} catch (Exception $e) {
Log::error(sprintf('Error in %s processing: %s', $type, $e->getMessage()));
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function index($processParameter = false)
{
$disk = Storage::disk('sftpStatement');
if ($processParameter) {
foreach (self::PARAMETER_PROCESSES as $process) {
$this->processData($process, '_parameter');
}
return response()->json(['message' => 'Parameter processes completed successfully']);
}
$period = date('Ymd', strtotime('-1 day'));
if (!$disk->exists($period)) {
return response()->json([
"message" => "Period {$period} folder not found in SFTP storage"
], 404);
}
foreach (self::DATA_PROCESSES as $process) {
$this->processData($process, $period);
}
return response()->json([
'message' => 'Data Customer processing job has been successfully queued',
'periods' => $periods
'message' => "Data processing for period {$period} has been queued successfully"
]);
} catch (Exception $e) {
Log::error('Error in processCustomerData: ' . $e->getMessage());
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processBillDetailData($periods)
{
try {
ProcessBillDetailDataJob::dispatch($periods);
return response()->json(['message' => 'Data Bill Details processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processAccountData($periods){
try{
ProcessAccountDataJob::dispatch($periods);
return response()->json(['message' => 'Data Account processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processTransactionData($periods){
try{
ProcessTransactionDataJob::dispatch($periods);
Log::info('Data Transaction processing job has been successfully');
return response()->json(['message' => 'Data Transaction processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processFundsTransferData($periods){
try{
ProcessFundsTransferDataJob::dispatch($periods);
return response()->json(['message' => 'Data Funds Transfer processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processStmtNarrParamData($periods)
{
try {
ProcessStmtNarrParamDataJob::dispatch($periods);
return response()->json(['message' => 'Data TempStmtNarrParam processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processStmtNarrFormatData($periods){
try {
ProcessStmtNarrFormatDataJob::dispatch($periods);
return response()->json(['message' => 'Data TempStmtNarrFormat processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessFtTxnTypeConditioData($periods){
try {
ProcessFtTxnTypeConditionJob::dispatch($periods);
return response()->json(['message' => 'FtTxnTypeCondition processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function processStmtEntryData($periods){
try {
ProcessStmtEntryDataJob::dispatch($periods);
return response()->json(['message' => 'Stmt Entry processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessCompanyData($periods){
try {
ProcessCompanyDataJob::dispatch($periods);
return response()->json(['message' => 'Company processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessDataCaptureData($periods){
try {
ProcessDataCaptureDataJob::dispatch($periods);
return response()->json(['message' => 'Data Capture processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessCategoryData($periods){
try {
ProcessCategoryDataJob::dispatch($periods);
return response()->json(['message' => 'Category processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessTellerData($periods){
try {
ProcessTellerDataJob::dispatch($periods);
return response()->json(['message' => 'Teller processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function ProcessAtmTransaction($periods){
try {
ProcessAtmTransactionJob::dispatch($periods);
return response()->json(['message' => 'AtmTransaction processing job has been successfully']);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function index()
{
$disk = Storage::disk('sftpStatement');
// Get all directories (periods) in the SFTP disk
$allDirectories = $disk->directories();
//$this->processTransactionData(['_parameter']);
//$this->processStmtNarrParamData(['_parameter']);
//$this->processStmtNarrFormatData(['_parameter']);
//$this->ProcessFtTxnTypeConditioData(['_parameter']);
// Filter out the _parameter folder
$periods = array_filter($allDirectories, function($dir) {
return $dir !== '_parameter';
});
// Sort periods by date (descending)
usort($periods, function($a, $b) {
return strcmp($b, $a); // Reverse comparison for descending order
});
if (empty($periods)) {
return response()->json(['message' => 'No valid period folders found in SFTP storage'], 404);
}
$this->ProcessCategoryData($periods);
//$this->processCustomerData($periods);
//$this->processAccountData($periods);
//$this->processStmtEntryData($periods);
//$this->ProcessDataCaptureData($periods);
//$this->processFundsTransferData($periods);
$this->ProcessTellerData($periods);
$this->ProcessAtmTransaction($periods);
//$this->processArrangementData($periods);
//$this->processBillDetailData($periods);
return response()->json(['message' => 'Data processing job has been successfully']);
}
}

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

@@ -5,14 +5,9 @@
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Jobs\ExportStatementJob;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempFundsTransfer;
use Modules\Webstatement\Models\TempStmtNarrFormat;
use Modules\Webstatement\Models\TempStmtNarrParam;
use Modules\Webstatement\Models\AccountBalance;
use Modules\Webstatement\Jobs\ExportStatementPeriodJob;
class WebstatementController extends Controller
{
@@ -21,296 +16,183 @@
*/
public function index()
{
$data = [[
'account_number' => '1080425781',
'period' => '2025012',
'saldo' => '23984352604'
],[
'account_number' => '1080425781',
'period' => '2025013',
'saldo' => '13984352604'
]];
$jobIds = [];
$data = [];
// Process each data entry
foreach ($data as $entry) {
// Dispatch job for each entry
$job = new ExportStatementJob(
$entry['account_number'],
$entry['period'],
$entry['saldo']
);
$jobIds[] = app(Dispatcher::class)->dispatch($job);
foreach ($this->listAccount() as $clientName => $accounts) {
foreach ($accounts as $accountNumber) {
foreach ($this->listPeriod() as $period) {
$job = new ExportStatementJob(
$accountNumber,
$period,
$this->getAccountBalance($accountNumber, $period),
$clientName // Pass the client name to the job
);
$jobIds[] = app(Dispatcher::class)->dispatch($job);
$data[] = [
'client_name' => $clientName,
'account_number' => $accountNumber,
'period' => $period
];
}
}
}
return response()->json([
'message' => 'Statement export jobs have been queued',
'jobs' => array_map(function($index, $jobId) use ($data) {
'jobs' => array_map(function ($index, $jobId) use ($data) {
return [
'job_id' => $jobId,
'job_id' => $jobId,
'client_name' => $data[$index]['client_name'],
'account_number' => $data[$index]['account_number'],
'period' => $data[$index]['period'],
'file_name' => "{$data[$index]['account_number']}_{$data[$index]['period']}.csv"
'period' => $data[$index]['period'],
'file_name' => "{$data[$index]['client_name']}_{$data[$index]['account_number']}_{$data[$index]['period']}.csv"
];
}, array_keys($jobIds), $jobIds)
]);
}
/**
* Download a previously exported statement
*/
public function downloadStatement(Request $request)
{
$account_number = $request->input('account_number', '1080425781');
$period = $request->input('period', '20250512');
$fileName = "{$account_number}_{$period}.csv";
$filePath = "statements/{$fileName}";
if (!Storage::disk('local')->exists($filePath)) {
return response()->json([
'message' => 'Statement file not found. It may still be processing.'
], 404);
}
return Storage::disk('local')->download($filePath, $fileName, [
"Content-Type" => "text/csv",
]);
}
/**
* Generate statement on-demand and return as download
*/
public function generateAndDownload(Request $request)
{
$account_number = $request->input('account_number', '1080425781');
$period = $request->input('period', '20250512');
$saldo = $request->input('saldo', '23984352604');
$stmt = StmtEntry::with(['ft', 'transaction'])
->where('account_number', $account_number)
->where('booking_date', $period)
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
->get();
if ($stmt->isEmpty()) {
return response()->json([
'message' => 'No statement data found for the specified account and period.'
], 404);
}
$runningBalance = (float) $saldo;
// Map the data to transform or format specific fields
$mappedData = $stmt->sortBy(['ACTUAL.DATE', 'REFERENCE.NUMBER'])
->map(function ($item, $index) use (&$runningBalance) {
$runningBalance += (float) $item->amount_lcy;
return [
'NO' => 0, // Will be updated later
'TRANSACTION.DATE' => Carbon::createFromFormat('YmdHi', $item->booking_date . substr($item->ft?->date_time ?? '0000000000', 6, 4))
->format('d/m/Y H:i'),
'REFERENCE.NUMBER' => $item->trans_reference,
'TRANSACTION.AMOUNT' => $item->amount_lcy,
'TRANSACTION.TYPE' => $item->amount_lcy < 0 ? 'D' : 'C',
'DESCRIPTION' => $this->generateNarrative($item),
'END.BALANCE' => $runningBalance,
'ACTUAL.DATE' => Carbon::createFromFormat('ymdHi', $item->ft?->date_time ?? '2505120000')
->format('d/m/Y H:i'),
];
})
->values();
// Then apply the sequential numbers
$mappedData = $mappedData->map(function ($item, $index) {
$item['NO'] = $index + 1;
return $item;
});
$csvFileName = $account_number . "_" . $period . ".csv";
$headers = [
"Content-Type" => "text/csv",
"Content-Disposition" => "attachment; filename={$csvFileName}"
function listAccount(){
return [
'PLUANG' => [
'1080426085',
'1080425781',
],
'OY' => [
'1081647484',
'1081647485',
],
'INDORAYA' => [
'1083123710',
'1083123711',
'1083123712',
'1083123713',
'1083123714',
'1083123715',
'1083123716',
'1083123718',
'1083123719',
'1083123721',
'1083123722',
'1083123723',
'1083123724',
'1083123726',
'1083123727',
'1083123728',
'1083123730',
'1083123731',
'1083123732',
'1083123734',
'1083123735',
],
'TDC' => [
'1086677889',
'1086677890',
'1086677891',
'1086677892',
'1086677893',
'1086677894',
'1086677895',
'1086677896',
'1086677897',
],
'ASIA_PARKING' => [
'1080119298',
'1080119361',
'1080119425',
'1080119387',
'1082208069',
],
'DAU' => [
'1085151668',
],
'EGR' => [
'1085368601',
],
'SARANA_PACTINDO' => [
'1078333878',
],
'SWADAYA_PANDU' => [
'0081272689',
]
];
$callback = function () use ($mappedData) {
$file = fopen('php://output', 'w');
// Write headers without quotes, using pipe separator
fputs($file, implode('|', array_keys($mappedData[0])) . "\n");
// Write data rows without quotes, using pipe separator
foreach ($mappedData as $row) {
fputs($file, implode('|', $row) . "\n");
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
/**
* Generate narrative for a statement entry
*/
private function generateNarrative($item)
{
$narr = '';
if ($item->transaction && $item->transaction->narr_type) {
$narr .= $item->transaction->stmt_narr . ' ';
$narr .= $this->getFormatNarrative($item->transaction->narr_type, $item);
} else if ($item->transaction) {
$narr .= $item->transaction->stmt_narr . ' ';
}
if ($item->ft && $item->ft->recipt_no) {
$narr .= 'Receipt No: ' . $item->ft->recipt_no;
}
return $narr;
function listPeriod(){
return [
date('Ymd', strtotime('-1 day'))
];
}
/**
* Get formatted narrative based on narrative type
*/
private function getFormatNarrative($narr, $item)
function getAccountBalance($accountNumber, $period)
{
$narrParam = TempStmtNarrParam::where('_id', $narr)->first();
$accountBalance = AccountBalance::where('account_number', $accountNumber)
->where('period', '<', $period)
->orderBy('period', 'desc')
->first();
if (!$narrParam) {
return '';
}
return $accountBalance->actual_balance ?? 0;
}
$fmt = '';
if ($narrParam->_id == 'FTIN') {
$fmt = 'FT.IN';
} else if ($narrParam->_id == 'FTOUT') {
$fmt = 'FT.IN';
} else {
$fmt = $narrParam->_id;
}
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
function printStatementRekening($accountNumber, $period = null) {
$period = $period ?? date('Ym');
$balance = AccountBalance::where('account_number', $accountNumber)
->when($period === '202505', function($query) {
return $query->where('period', '>=', '20250512')
->orderBy('period', 'asc');
}, function($query) use ($period) {
// Get balance from last day of previous month
$firstDayOfMonth = Carbon::createFromFormat('Ym', $period)->startOfMonth();
$lastDayPrevMonth = $firstDayOfMonth->copy()->subDay()->format('Ymd');
return $query->where('period', $lastDayPrevMonth);
})
->first()
->actual_balance ?? '0.00';
$clientName = 'client1';
if (!$narrFormat) {
return '';
}
try {
\Log::info("Starting statement export for account: {$accountNumber}, period: {$period}, client: {$clientName}");
// Get the format string from the database
$formatString = $narrFormat->text_data ?? '';
// Parse the format string
// Split by the separator ']'
$parts = explode(']', $formatString);
$result = '';
foreach ($parts as $index => $part) {
if (empty($part)) {
continue;
// Validate inputs
if (empty($accountNumber) || empty($period) || empty($clientName)) {
throw new \Exception('Required parameters missing');
}
if ($index === 0) {
// For the first part, take only what's before the '!'
$splitPart = explode('!', $part);
if (count($splitPart) > 0) {
// Remove quotes, backslashes, and other escape characters
$cleanPart = trim($splitPart[0]);
// Remove quotes at the beginning and end
$cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart);
// Remove any remaining backslashes
$cleanPart = str_replace('\\', '', $cleanPart);
// Remove any remaining quotes
$cleanPart = str_replace('"', '', $cleanPart);
$result .= $cleanPart;
}
} else {
// For other parts, these are field placeholders
$fieldName = strtolower(str_replace('.', '_', $part));
// Dispatch the job
$job = ExportStatementPeriodJob::dispatch($accountNumber, $period, $balance, $clientName);
// Get the corresponding parameter value from narrParam
$paramValue = null;
// Check if the field exists as a property in narrParam
if (property_exists($narrParam, $fieldName)) {
$paramValue = $narrParam->$fieldName;
} else if (isset($narrParam->$fieldName)) {
$paramValue = $narrParam->$fieldName;
}
// If we found a value, add it to the result
if ($paramValue !== null) {
$result .= $paramValue;
} else {
// If no value found, try to use the original field name as a fallback
if ($fieldName != 'recipt_no') {
$result .= $this->getTransaction($item->trans_reference, $fieldName) . ' ';
}
}
}
}
return $result;
}
/**
* Get transaction data by reference and field
*/
private function getTransaction($ref, $field)
{
$trans = TempFundsTransfer::where('ref_no', $ref)->first();
return $trans ? ($trans->$field ?? "") : "";
}
/**
* Queue a statement export job and return job ID
*/
public function queueExport(Request $request)
{
$account_number = $request->input('account_number', '1080425781');
$period = $request->input('period', '20250512');
$saldo = $request->input('saldo', '23984352604');
// Dispatch the job and get the job ID
$job = new ExportStatementJob($account_number, $period, $saldo);
$jobId = app(Dispatcher::class)->dispatch($job);
return response()->json([
'message' => 'Statement export job has been queued',
'job_id' => $jobId,
'account_number' => $account_number,
'period' => $period,
'file_name' => "{$account_number}_{$period}.csv"
]);
}
/**
* Check the status of an export job
*/
public function checkExportStatus(Request $request, $jobId)
{
// Get job status from the queue
$job = DB::table('jobs')
->where('id', $jobId)
->first();
if (!$job) {
// Check if job is completed
$completedJob = DB::table('job_batches')
->where('id', $jobId)
->first();
if ($completedJob) {
return response()->json([
'status' => 'completed',
'message' => 'Export job has been completed'
]);
}
\Log::info("Statement export job dispatched successfully", [
'job_id' => $job->job_id ?? null,
'account' => $accountNumber,
'period' => $period,
'client' => $clientName
]);
return response()->json([
'status' => 'not_found',
'message' => 'Export job not found'
], 404);
}
'success' => true,
'message' => 'Statement export job queued successfully',
'data' => [
'job_id' => $job->job_id ?? null,
'account_number' => $accountNumber,
'period' => $period,
'client_name' => $clientName
]
]);
return response()->json([
'status' => 'pending',
'message' => 'Export job is still processing'
]);
} catch (\Exception $e) {
\Log::error("Failed to export statement", [
'error' => $e->getMessage(),
'account' => $accountNumber,
'period' => $period
]);
return response()->json([
'success' => false,
'message' => 'Failed to queue statement export job',
'error' => $e->getMessage()
]);
}
}
}

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',
]);
}
}
}

138
app/Jobs/CombinePdfJob.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
use Owenoj\PDFPasswordProtect\Facade\PDFPasswordProtect;
use Webklex\PDFMerger\Facades\PDFMergerFacade as PDFMerger;
class CombinePdfJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pdfFiles;
protected $outputPath;
protected $outputFilename;
protected $password;
protected $outputDestination;
protected $branchCode;
protected $period;
/**
* Create a new job instance.
*
* @param array $pdfFiles Array of PDF file paths to combine
* @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, 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;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
// Initialize the PDF merger
$merger = PDFMerger::init();
// Add each PDF file to the merger
foreach ($this->pdfFiles as $pdfFile) {
if (file_exists($pdfFile)) {
$merger->addPDF($pdfFile, 'all');
} else {
Log::warning("PDF file not found: {$pdfFile}");
}
}
// Make sure the output directory exists
if (!file_exists($this->outputPath)) {
mkdir($this->outputPath, 0755, true);
}
// Merge the PDFs
$merger->merge();
// Save the merged PDF
$fullPath = $this->outputPath . '/' . $this->outputFilename;
$merger->save($fullPath);
// Apply password protection if password is provided
if (!empty($this->password)) {
$tempPath = $this->outputPath . '/temp_' . $this->outputFilename;
// Rename the original file to a temporary name
rename($fullPath, $tempPath);
// Apply password protection and save to the original filename
PDFPasswordProtect::encrypt($tempPath, $fullPath, $this->password);
// Remove the temporary file
if (file_exists($tempPath)) {
unlink($tempPath);
}
Log::info("PDF password protection applied successfully.");
}
// 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,105 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Barryvdh\DomPDF\Facade\Pdf;
class ConvertHtmlToPdfJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $baseDirectory;
/**
* Create a new job instance.
*
* @param string $baseDirectory Base directory path to scan
*/
public function __construct(string $baseDirectory)
{
$this->baseDirectory = $baseDirectory;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info("Starting HTML to PDF conversion in directory: {$this->baseDirectory}");
// Check if directory exists
if (!File::isDirectory($this->baseDirectory)) {
Log::error("Directory not found: {$this->baseDirectory}");
return;
}
// Get all subdirectories (ID folders)
$idDirectories = File::directories($this->baseDirectory);
foreach ($idDirectories as $idDirectory) {
$this->processDirectory($idDirectory);
}
Log::info("HTML to PDF conversion completed successfully.");
} catch (Exception $e) {
Log::error("Error converting HTML to PDF: " . $e->getMessage());
}
}
/**
* Process a single ID directory
*
* @param string $directory Directory path to process
*/
protected function processDirectory(string $directory): void
{
try {
$htmlFiles = File::glob($directory . '/*.html');
foreach ($htmlFiles as $htmlFile) {
$this->convertHtmlToPdf($htmlFile);
}
} catch (Exception $e) {
Log::error("Error processing directory {$directory}: " . $e->getMessage());
}
}
/**
* Convert a single HTML file to PDF
*
* @param string $htmlFilePath Path to HTML file
*/
protected function convertHtmlToPdf(string $htmlFilePath): void
{
try {
$filename = pathinfo($htmlFilePath, PATHINFO_FILENAME);
$directory = pathinfo($htmlFilePath, PATHINFO_DIRNAME);
$pdfFilePath = $directory . '/' . $filename . '.pdf';
// Read HTML content
$htmlContent = File::get($htmlFilePath);
// Convert HTML to PDF with A4 size
$pdf = PDF::loadHTML($htmlContent);
// Set paper size to A4
$pdf->setPaper('A4', 'portrait');
// Save PDF file
$pdf->save($pdfFilePath);
Log::info("Converted {$htmlFilePath} to {$pdfFilePath} with A4 size");
} catch (Exception $e) {
Log::error("Error converting {$htmlFilePath} to PDF: " . $e->getMessage());
}
}
}

View File

@@ -26,6 +26,7 @@
protected $period;
protected $saldo;
protected $disk;
protected $client;
protected $fileName;
protected $chunkSize = 1000; // Proses data dalam chunk untuk mengurangi penggunaan memori
@@ -37,12 +38,13 @@
* @param string $saldo
* @param string $disk
*/
public function __construct(string $account_number, string $period, string $saldo, string $disk = 'local')
public function __construct(string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local')
{
$this->account_number = $account_number;
$this->period = $period;
$this->saldo = $saldo;
$this->disk = $disk;
$this->client = $client;
$this->fileName = "{$account_number}_{$period}.csv";
}
@@ -55,15 +57,7 @@
try {
Log::info("Starting export statement job for account: {$this->account_number}, period: {$this->period}");
// Cek apakah data sudah diproses sebelumnya
$existingData = ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->exists();
if (!$existingData) {
// Jika belum ada data yang diproses, lakukan pemrosesan
$this->processStatementData();
}
$this->processStatementData();
// Export data yang sudah diproses ke CSV
$this->exportToCsv();
@@ -75,96 +69,184 @@
}
}
/**
* Process statement data and save to database
*/
private function processStatementData()
: void
{
// Hapus data yang mungkin sudah ada untuk kombinasi account dan period yang sama
ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->delete();
$accountQuery = [
'account_number' => $this->account_number,
'period' => $this->period
];
$totalCount = $this->getTotalEntryCount($accountQuery);
$existingDataCount = $this->getExistingProcessedCount($accountQuery);
// Hanya proses jika data belum lengkap diproses
if ($existingDataCount !== $totalCount) {
$this->deleteExistingProcessedData($accountQuery);
$this->processAndSaveStatementEntries($totalCount);
}
}
private function getTotalEntryCount(array $criteria)
: int
{
return StmtEntry::where('account_number', $criteria['account_number'])
->where('booking_date', $criteria['period'])
->count();
}
private function getExistingProcessedCount(array $criteria)
: int
{
return ProcessedStatement::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->count();
}
private function deleteExistingProcessedData(array $criteria)
: void
{
ProcessedStatement::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->delete();
}
private function processAndSaveStatementEntries(int $totalCount)
: void
{
$runningBalance = (float) $this->saldo;
$totalCount = StmtEntry::where('account_number', $this->account_number)
->where('booking_date', $this->period)
->count();
$globalSequence = 0;
Log::info("Processing {$totalCount} statement entries for account: {$this->account_number}");
// Track the global sequence number across chunks
$globalSequence = 0;
// Proses data dalam chunk untuk mengurangi penggunaan memori
StmtEntry::with(['ft', 'transaction'])
->where('account_number', $this->account_number)
->where('booking_date', $this->period)
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
->chunk($this->chunkSize, function ($entries) use (&$runningBalance, &$globalSequence) {
$processedData = [];
$processedData = $this->prepareProcessedData($entries, $runningBalance, $globalSequence);
foreach ($entries as $item) {
$globalSequence++; // Increment the global sequence counter
$runningBalance += (float) $item->amount_lcy;
try {
$transactionDate = Carbon::createFromFormat('YmdHi', $item->booking_date . substr($item->ft?->date_time ?? '0000000000', 6, 4))
->format('d/m/Y H:i');
} catch (Exception $e) {
$transactionDate = Carbon::now()->format('d/m/Y H:i');
Log::warning("Error formatting transaction date: " . $e->getMessage());
}
try {
$actualDate = Carbon::createFromFormat('ymdHi', $item->ft?->date_time ?? '2505120000')
->format('d/m/Y H:i');
} catch (Exception $e) {
$actualDate = Carbon::now()->format('d/m/Y H:i');
Log::warning("Error formatting actual date: " . $e->getMessage());
}
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $transactionDate,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'created_at' => now(),
'updated_at' => now(),
];
}
// var_dump($processedData);
// Simpan data dalam batch untuk kinerja yang lebih baik
if (!empty($processedData)) {
DB::table('processed_statements')->insert($processedData);
}
});
}
private function prepareProcessedData($entries, &$runningBalance, &$globalSequence)
: array
{
$processedData = [];
foreach ($entries as $item) {
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $transactionDate,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'created_at' => now(),
'updated_at' => now(),
];
}
return $processedData;
}
private function formatTransactionDate($item)
: string
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'YmdHi',
$item->booking_date . substr($datetime, 6, 4)
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting transaction date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
private function formatActualDate($item)
: string
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'ymdHi',
$datetime
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting actual date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
/**
* Generate narrative for a statement entry
*/
private function generateNarrative($item)
{
$narr = '';
if ($item->transaction->narr_type) {
$narr .= $item->transaction->stmt_narr . ' ';
$narr .= $this->getFormatNarrative($item->transaction->narr_type, $item);
} else {
$narr .= $item->transaction->stmt_narr . ' ';
$narr = [];
if ($item->transaction) {
if ($item->transaction->stmt_narr) {
$narr[] = $item->transaction->stmt_narr;
}
if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->transaction->narr_type) {
$narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item);
}
} else if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->ft?->recipt_no) {
$narr .= 'Receipt No: ' . $item->ft->recipt_no;
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
}
return $narr;
return implode(' ', array_filter($narr));
}
/**
@@ -183,7 +265,17 @@
$fmt = 'FT.IN';
} else if ($narrParam->_id == 'FTOUT') {
$fmt = 'FT.OUT';
} else {
} else if ($narrParam->_id == 'TTTRFOUT') {
$fmt = 'TT.O.TRF';
} else if ($narrParam->_id == 'TTTRFIN') {
$fmt = 'TT.I.TRF';
} else if ($narrParam->_id == 'APITRX'){
$fmt = 'API.TSEL';
} else if ($narrParam->_id == 'ONUSCR'){
$fmt = 'ONUS.CR';
} else if ($narrParam->_id == 'ONUSDR'){
$fmt = 'ONUS.DR';
}else {
$fmt = $narrParam->_id;
}
@@ -212,7 +304,7 @@
$splitPart = explode('!', $part);
if (count($splitPart) > 0) {
// Remove quotes, backslashes, and other escape characters
$cleanPart = trim($splitPart[0]);
$cleanPart = trim($splitPart[0]).' ';
// Remove quotes at the beginning and end
$cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart);
// Remove any remaining backslashes
@@ -240,23 +332,25 @@
$result .= $paramValue;
} else {
// If no value found, try to use the original field name as a fallback
if ($fieldName != 'recipt_no') {
$result .= $this->getTransaction($item->trans_reference, $fieldName) . ' ';
if ($fieldName !== 'recipt_no') {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$result .= ($item->$relation?->$fieldName ?? '') . ' ';
}
}
}
}
}
return $result;
}
/**
* Get transaction data by reference and field
*/
private function getTransaction($ref, $field)
{
$trans = TempFundsTransfer::where('ref_no', $ref)->first();
return $trans->$field ?? "";
return str_replace('<NL>', ' ', $result);
}
/**
@@ -265,13 +359,34 @@
private function exportToCsv()
: void
{
// Determine the base path based on client
$basePath = !empty($this->client)
? "statements/{$this->client}"
: "statements";
// Create client directory if it doesn't exist
if (!empty($this->client)) {
Storage::disk($this->disk)->makeDirectory($basePath);
}
// Create account directory
$accountPath = "{$basePath}/{$this->account_number}";
Storage::disk($this->disk)->makeDirectory($accountPath);
$filePath = "{$accountPath}/{$this->fileName}";
// Delete existing file if it exists
if (Storage::disk($this->disk)->exists($filePath)) {
Storage::disk($this->disk)->delete($filePath);
}
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE\n";
// Ambil data yang sudah diproses dalam chunk untuk mengurangi penggunaan memori
ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->orderBy('sequence_no')
->chunk($this->chunkSize, function ($statements) use (&$csvContent) {
->chunk($this->chunkSize, function ($statements) use (&$csvContent, $filePath) {
foreach ($statements as $statement) {
$csvContent .= implode('|', [
$statement->sequence_no,
@@ -286,10 +401,19 @@
}
// Tulis ke file secara bertahap untuk mengurangi penggunaan memori
Storage::disk($this->disk)->append("statements/{$this->fileName}", $csvContent);
Storage::disk($this->disk)->append($filePath, $csvContent);
$csvContent = ''; // Reset content setelah ditulis
});
Log::info("Statement exported to {$this->disk} disk: statements/{$this->fileName}");
Log::info("Statement exported to {$this->disk} disk: {$filePath}");
}
/**
* Get transaction data by reference and field
*/
private function getTransaction($ref, $field)
{
$trans = TempFundsTransfer::where('ref_no', $ref)->first();
return $trans->$field ?? "";
}
}

View File

@@ -0,0 +1,443 @@
<?php
namespace Modules\Webstatement\Jobs;
use Carbon\Carbon;
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\ProcessedStatement;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempFundsTransfer;
use Modules\Webstatement\Models\TempStmtNarrFormat;
use Modules\Webstatement\Models\TempStmtNarrParam;
class ExportStatementPeriodJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $account_number;
protected $period; // Format: YYYYMM (e.g., 202505)
protected $saldo;
protected $disk;
protected $client;
protected $fileName;
protected $chunkSize = 1000;
protected $startDate;
protected $endDate;
/**
* Create a new job instance.
*
* @param string $account_number
* @param string $period Format: YYYYMM (e.g., 202505)
* @param string $saldo
* @param string $client
* @param string $disk
*/
public function __construct(string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local')
{
$this->account_number = $account_number;
$this->period = $period;
$this->saldo = $saldo;
$this->disk = $disk;
$this->client = $client;
$this->fileName = "{$account_number}_{$period}.csv";
// Calculate start and end dates based on period
$this->calculatePeriodDates();
}
/**
* Calculate start and end dates for the given period
*/
private function calculatePeriodDates(): void
{
$year = substr($this->period, 0, 4);
$month = substr($this->period, 4, 2);
// Special case for May 2025 - start from 12th
if ($this->period === '202505') {
$this->startDate = Carbon::createFromDate($year, $month, 12)->startOfDay();
} else {
// For all other periods, start from 1st of the month
$this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
}
// End date is always the last day of the month
$this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay();
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info("Starting export statement period job for account: {$this->account_number}, period: {$this->period}");
Log::info("Date range: {$this->startDate->format('Y-m-d')} to {$this->endDate->format('Y-m-d')}");
$this->processStatementData();
$this->exportToCsv();
Log::info("Export statement period job completed successfully for account: {$this->account_number}, period: {$this->period}");
} catch (Exception $e) {
Log::error("Error in ExportStatementPeriodJob: " . $e->getMessage());
throw $e;
}
}
private function processStatementData(): void
{
$accountQuery = [
'account_number' => $this->account_number,
'period' => $this->period
];
$totalCount = $this->getTotalEntryCount();
$existingDataCount = $this->getExistingProcessedCount($accountQuery);
// Only process if data is not fully processed
if ($existingDataCount !== $totalCount) {
$this->deleteExistingProcessedData($accountQuery);
$this->processAndSaveStatementEntries($totalCount);
}
}
private function getTotalEntryCount(): int
{
return StmtEntry::where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
])
->count();
}
private function getExistingProcessedCount(array $criteria): int
{
return ProcessedStatement::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->count();
}
private function deleteExistingProcessedData(array $criteria): void
{
ProcessedStatement::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->delete();
}
private function processAndSaveStatementEntries(int $totalCount): void
{
$runningBalance = (float) $this->saldo;
$globalSequence = 0;
Log::info("Processing {$totalCount} statement entries for account: {$this->account_number}");
StmtEntry::with(['ft', 'transaction'])
->where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
])
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
->chunk($this->chunkSize, function ($entries) use (&$runningBalance, &$globalSequence) {
$processedData = $this->prepareProcessedData($entries, $runningBalance, $globalSequence);
if (!empty($processedData)) {
DB::table('processed_statements')->insert($processedData);
}
});
}
private function prepareProcessedData($entries, &$runningBalance, &$globalSequence): array
{
$processedData = [];
foreach ($entries as $item) {
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $transactionDate,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'created_at' => now(),
'updated_at' => now(),
];
}
return $processedData;
}
private function formatTransactionDate($item): string
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
// Extract date from datetime (first 6 characters) and time (last 4 characters)
$dateStr = substr($datetime, 0, 6); // YYMMDD
$timeStr = substr($datetime, 6, 4); // HHMM
return Carbon::createFromFormat(
'ymdHi',
$dateStr . $timeStr
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting transaction date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
private function formatActualDate($item): string
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'ymdHi',
$datetime
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting actual date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
/**
* Generate narrative for a statement entry
*/
private function generateNarrative($item)
{
$narr = [];
if ($item->transaction) {
if ($item->transaction->stmt_narr) {
$narr[] = $item->transaction->stmt_narr;
}
if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->transaction->narr_type) {
$narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item);
}
} else if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->ft?->recipt_no) {
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
}
return implode(' ', array_filter($narr));
}
/**
* Get formatted narrative based on narrative type
*/
private function getFormatNarrative($narr, $item)
{
$narrParam = TempStmtNarrParam::where('_id', $narr)->first();
if (!$narrParam) {
return '';
}
$fmt = '';
if ($narrParam->_id == 'FTIN') {
$fmt = 'FT.IN';
} else if ($narrParam->_id == 'FTOUT') {
$fmt = 'FT.OUT';
} else if ($narrParam->_id == 'TTTRFOUT') {
$fmt = 'TT.O.TRF';
} else if ($narrParam->_id == 'TTTRFIN') {
$fmt = 'TT.I.TRF';
} else if ($narrParam->_id == 'APITRX'){
$fmt = 'API.TSEL';
} else if ($narrParam->_id == 'ONUSCR'){
$fmt = 'ONUS.CR';
} else if ($narrParam->_id == 'ONUSDR'){
$fmt = 'ONUS.DR';
}else {
$fmt = $narrParam->_id;
}
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
if (!$narrFormat) {
return '';
}
// Get the format string from the database
$formatString = $narrFormat->text_data ?? '';
// Parse the format string
// Split by the separator ']'
$parts = explode(']', $formatString);
$result = '';
foreach ($parts as $index => $part) {
if (empty($part)) {
continue;
}
if ($index === 0) {
// For the first part, take only what's before the '!'
$splitPart = explode('!', $part);
if (count($splitPart) > 0) {
// Remove quotes, backslashes, and other escape characters
$cleanPart = trim($splitPart[0]).' ';
// Remove quotes at the beginning and end
$cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart);
// Remove any remaining backslashes
$cleanPart = str_replace('\\', '', $cleanPart);
// Remove any remaining quotes
$cleanPart = str_replace('"', '', $cleanPart);
$result .= $cleanPart;
}
} else {
// For other parts, these are field placeholders
$fieldName = strtolower(str_replace('.', '_', $part));
// Get the corresponding parameter value from narrParam
$paramValue = null;
// Check if the field exists as a property in narrParam
if (property_exists($narrParam, $fieldName)) {
$paramValue = $narrParam->$fieldName;
} else if (isset($narrParam->$fieldName)) {
$paramValue = $narrParam->$fieldName;
}
// If we found a value, add it to the result
if ($paramValue !== null) {
$result .= $paramValue;
} else {
// If no value found, try to use the original field name as a fallback
if ($fieldName !== 'recipt_no') {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$result .= ($item->$relation?->$fieldName ?? '') . ' ';
}
}
}
}
}
return str_replace('<NL>', ' ', $result);
}
/**
* Export processed data to CSV file
*/
private function exportToCsv(): void
{
// Determine the base path based on client
$basePath = !empty($this->client)
? "statements/{$this->client}"
: "statements";
// Create client directory if it doesn't exist
if (!empty($this->client)) {
Storage::disk($this->disk)->makeDirectory($basePath);
}
// Create account directory
$accountPath = "{$basePath}/{$this->account_number}";
Storage::disk($this->disk)->makeDirectory($accountPath);
$filePath = "{$accountPath}/{$this->fileName}";
// Delete existing file if it exists
if (Storage::disk($this->disk)->exists($filePath)) {
Storage::disk($this->disk)->delete($filePath);
}
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE\n";
// Retrieve processed data in chunks to reduce memory usage
ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->orderBy('sequence_no')
->chunk($this->chunkSize, function ($statements) use (&$csvContent, $filePath) {
foreach ($statements as $statement) {
$csvContent .= implode('|', [
$statement->sequence_no,
$statement->transaction_date,
$statement->reference_number,
$statement->transaction_amount,
$statement->transaction_type,
$statement->description,
$statement->end_balance,
$statement->actual_date
]) . "\n";
}
// Write to file incrementally to reduce memory usage
Storage::disk($this->disk)->append($filePath, $csvContent);
$csvContent = ''; // Reset content after writing
});
Log::info("Statement exported to {$this->disk} disk: {$filePath}");
}
/**
* Get transaction data by reference and field
*/
private function getTransaction($ref, $field)
{
$trans = TempFundsTransfer::where('ref_no', $ref)->first();
return $trans->$field ?? "";
}
}

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

@@ -1,123 +1,269 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Account;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\AccountBalance;
class ProcessAccountDataJob implements ShouldQueue
class ProcessAccountDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.ACCOUNT.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 $balanceData = [];
private $accountBatch = [];
private $balanceBatch = [];
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
$this->period = $period;
}
protected $periods;
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
{
$this->periods = $periods;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
if (empty($this->periods)) {
Log::warning('No periods provided for account data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.ACCOUNT.csv";
$filePath = "$period/$filename";
Log::info("Processing account file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new Account())->getFillable();
Log::info('Headers: ' . implode(", ", $headers));
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
// Check if start_year_bal is empty and set it to 0 if so
if (empty($data['start_year_bal']) || $data['start_year_bal'] == "" || $data['start_year_bal'] == null) {
$data['start_year_bal'] = 0;
}
if (empty($data['closure_date']) || $data['closure_date'] == "" || $data['closure_date'] == null) {
$data['closure_date'] = null;
}
try {
if ($data['account_number'] !== 'account_number') {
// Use firstOrNew instead of updateOrCreate
$account = Account::firstOrNew(['account_number' => $data['account_number']]);
$account->fill($data);
$account->save();
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Account at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Account data processing completed. Total processed: $processedCount, Total errors: $errorCount");
} catch (Exception $e) {
Log::error('Error in ProcessAccountDataJob: ' . $e->getMessage());
throw $e;
if ($this->period === '') {
Log::warning('No period provided for account data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessAccountDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->accountBatch = [];
$this->balanceBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing account file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new Account())->getFillable();
Log::info('Headers: ' . implode(", ", $headers));
$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->accountBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->accountBatch) || !empty($this->balanceBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->normalizeData($data);
$this->addToBatch($data, $rowCount, $filePath);
}
private function normalizeData(array &$data)
: void
{
// Check if start_year_bal is empty and set it to 0 if so
if (empty($data['start_year_bal']) || $data['start_year_bal'] == "" || $data['start_year_bal'] == null) {
$data['start_year_bal'] = 0;
}
if (empty($data['closure_date']) || $data['closure_date'] == "" || $data['closure_date'] == null) {
$data['closure_date'] = null;
}
// Store balance data separately before removing from Account data
$this->balanceData = [
'open_actual_bal' => empty($data['open_actual_bal']) ? 0 : $data['open_actual_bal'],
'open_cleared_bal' => empty($data['open_cleared_bal']) ? 0 : $data['open_cleared_bal'],
];
// Remove balance fields from Account data
unset($data['open_actual_bal']);
unset($data['open_cleared_bal']);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
if ($data['account_number'] !== 'account_number') {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to account batch
$this->accountBatch[] = $data;
// Add to balance batch
if (isset($this->balanceData['open_actual_bal']) || isset($this->balanceData['open_cleared_bal'])) {
$this->balanceBatch[] = [
'account_number' => $data['account_number'],
'period' => $this->period,
'actual_balance' => $this->balanceData['open_actual_bal'],
'cleared_balance' => $this->balanceData['open_cleared_bal'],
'created_at' => $now,
'updated_at' => $now
];
}
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Account at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->accountBatch)) {
// Bulk insert/update accounts
Account::upsert(
$this->accountBatch,
['account_number'], // Unique key
array_diff((new Account())->getFillable(), ['account_number']) // Update columns
);
// Reset account batch after processing
$this->accountBatch = [];
}
if (!empty($this->balanceBatch)) {
// Bulk insert/update account balances
AccountBalance::upsert(
$this->balanceBatch,
['account_number', 'period'], // Composite unique key
['actual_balance', 'cleared_balance', 'updated_at'] // Update columns
);
// Reset balance batch after processing
$this->balanceBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->accountBatch);
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Account data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -16,97 +16,202 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.AA.ARRANGEMENT.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 $arrangementBatch = [];
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle(): void
public function handle()
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for arrangement data processing');
if ($this->period === '') {
Log::warning('No period provided for arrangement data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.AA.ARRANGEMENT.csv";
$filePath = "$period/$filename";
Log::info("Processing arrangement file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new TempArrangement())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if ($data['arrangement_id'] !== 'arrangement_id') {
TempArrangement::updateOrCreate(
['arrangement_id' => $data['arrangement_id']], // key to find existing record
$data // data to update or create
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Arrangement at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Arrangement data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessArrangementDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->arrangementBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing arrangement file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new TempArrangement())->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->arrangementBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->arrangementBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
$this->errorCount++;
return;
}
$data = array_combine($headers, $row);
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
if ($data['arrangement_id'] !== 'arrangement_id') {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to batch
$this->arrangementBatch[] = $data;
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Arrangement at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->arrangementBatch)) {
// Bulk insert/update arrangements
TempArrangement::upsert(
$this->arrangementBatch,
['arrangement_id'], // Unique key
array_diff((new TempArrangement())->getFillable(), ['arrangement_id']) // Update columns
);
// Reset batch after processing
$this->arrangementBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->arrangementBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->arrangementBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Arrangement data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -16,13 +16,12 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const PARAMETER_FOLDER = '_parameter';
// Konstanta untuk nilai-nilai statis
private const FILE_EXTENSION = '.ST.ATM.csv';
private const CSV_DELIMITER = '~';
private const DISK_NAME = 'sftpStatement';
private const HEADER_MAP = [
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.ATM.TRANSACTION.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const HEADER_MAP = [
'id' => 'transaction_id',
'card_acc_id' => 'card_acc_id',
'pan_number' => 'pan_number',
@@ -42,163 +41,145 @@
'proc_code' => 'proc_code'
];
// Pemetaan bidang header ke kolom model
protected array $periods;
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $atmTransactionBatch = [];
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle(): void
public function handle()
: void
{
try {
set_time_limit(24 * 60 * 60);
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for ATM transaction data processing');
if ($this->period === '') {
Log::warning('No period provided for ATM transaction data processing');
return;
}
$stats = $this->processPeriods();
Log::info("ProcessAtmTransactionJob completed. Total processed: {$stats['processed']}, Total errors: {$stats['errors']}");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error("Error in ProcessAtmTransactionJob: " . $e->getMessage());
Log::error('Error in ProcessAtmTransactionJob: ' . $e->getMessage());
throw $e;
}
}
/**
* Process all periods and return statistics
*/
private function processPeriods(): array
private function initializeJob()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$processedCount = 0;
$errorCount = 0;
foreach ($this->periods as $period) {
// Skip the parameter folder
if ($period === self::PARAMETER_FOLDER) {
Log::info("Skipping " . self::PARAMETER_FOLDER . " folder");
continue;
}
$result = $this->processPeriodFile($disk, $period);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
}
return [
'processed' => $processedCount,
'errors' => $errorCount
];
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->atmTransactionBatch = [];
}
/**
* Process a single period file
*/
private function processPeriodFile($disk, string $period): array
private function processPeriod()
: void
{
$filename = $period . self::FILE_EXTENSION;
$filePath = "$period/$filename";
$processedCount = 0;
$errorCount = 0;
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing ATM transaction file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return ['processed' => 0, 'errors' => 0];
return false;
}
$tempFilePath = $this->createTempFile($disk, $filePath, $filename);
$result = $this->processCSVFile($tempFilePath, $filePath);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
// Clean up the temporary file
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
Log::info("Completed processing $filePath. Processed {$result['processed']} records with {$result['errors']} errors.");
return [
'processed' => $processedCount,
'errors' => $errorCount
];
return true;
}
/**
* Create a temporary file for processing
*/
private function createTempFile($disk, string $filePath, string $filename): string
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
/**
* Process a CSV file and import data
*/
private function processCSVFile(string $tempFilePath, string $originalFilePath): array
private function processFile(string $tempFilePath, string $filePath)
: void
{
$processedCount = 0;
$errorCount = 0;
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $originalFilePath");
return ['processed' => 0, 'errors' => 0];
Log::error("Unable to open file: $filePath");
return;
}
// Get the headers from the first row
$headerRow = fgetcsv($handle, 0, self::CSV_DELIMITER);
if (!$headerRow) {
fclose($handle);
return ['processed' => 0, 'errors' => 0];
return;
}
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($headerRow, $row, $rowCount, $filePath);
if (count($headerRow) !== count($row)) {
Log::warning("Row $rowCount in $originalFilePath has incorrect column count. Expected: " . count($headerRow) . ", Got: " . count($row));
continue;
// Process in chunks to avoid memory issues
if (count($this->atmTransactionBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
$result = $this->processRow($headerRow, $row, $rowCount, $originalFilePath);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
// Process any remaining records
if (!empty($this->atmTransactionBatch)) {
$this->saveBatch();
}
fclose($handle);
return [
'processed' => $processedCount,
'errors' => $errorCount
];
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
/**
* Process a single row from the CSV file
*/
private function processRow(array $headerRow, array $row, int $rowCount, string $filePath): array
private function processRow(array $headerRow, array $row, int $rowCount, string $filePath)
: void
{
if (count($headerRow) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headerRow) . ", Got: " . count($row));
$this->errorCount++;
return;
}
// Combine the header row with the data row
$rawData = array_combine($headerRow, $row);
$this->mapAndAddToBatch($rawData, $rowCount, $filePath);
}
private function mapAndAddToBatch(array $rawData, int $rowCount, string $filePath)
: void
{
// Map the raw data to our model fields
$data = [];
foreach (self::HEADER_MAP as $csvField => $modelField) {
@@ -207,29 +188,76 @@
// Skip header row if it was included in the data
if ($data['transaction_id'] === 'id') {
return ['processed' => 0, 'errors' => 0];
return;
}
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
// Format dates if needed
/*if (!empty($data['booking_date'])) {
$data['booking_date'] = date('Y-m-d H:i:s', strtotime($data['booking_date']));
}
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
if (!empty($data['value_date'])) {
$data['value_date'] = date('Y-m-d H:i:s', strtotime($data['value_date']));
}*/
// Create or update the record
AtmTransaction::updateOrCreate(
['transaction_id' => $data['transaction_id']],
$data
);
return ['processed' => 1, 'errors' => 0];
// Add to batch
$this->atmTransactionBatch[] = $data;
$this->processedCount++;
} catch (Exception $e) {
Log::error("Error processing row $rowCount in $filePath: " . $e->getMessage());
return ['processed' => 0, 'errors' => 1];
$this->errorCount++;
Log::error("Error processing ATM Transaction at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->atmTransactionBatch)) {
// Process in smaller chunks for better memory management
foreach ($this->atmTransactionBatch as $entry) {
// Extract all stmt_entry_ids from the current chunk
$entryIds = array_column($entry, 'transaction_id');
// Delete existing records with these IDs to avoid conflicts
AtmTransaction::whereIn('transaction_id', $entryIds)->delete();
// Insert all records in the chunk at once
AtmTransaction::insert($entry);
}
// Reset entry batch after processing
$this->atmTransactionBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->atmTransactionBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->atmTransactionBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("ATM transaction data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -1,112 +1,222 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempBillDetail;
use Exception;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempBillDetail;
class ProcessBillDetailDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
class ProcessBillDetailDataJob implements ShouldQueue
{
$this->periods = $periods;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle()
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.AA.BILL.DETAILS.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
if (empty($this->periods)) {
Log::warning('No periods provided for bill detail data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $billDetailBatch = [];
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No period provided for bill detail data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessBillDetailDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->billDetailBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
// Construct the filename based on the period folder name
$filename = "$period.ST.AA.BILL.DETAILS.csv";
$filePath = "$period/$filename";
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing bill detail file: $filePath");
Log::info("Processing bill detail file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
return true;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
$handle = fopen($tempFilePath, "r");
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
if ($handle !== false) {
$headers = (new TempBillDetail())->getFillable();
$rowCount = 0;
$headers = (new TempBillDetail())->getFillable();
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (isset($data['_id']) && $data['_id'] !== '_id') {
TempBillDetail::updateOrCreate(
['_id' => $data['_id']], // Fixed the syntax error here
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Bill Detail at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
// Process in chunks to avoid memory issues
if (count($this->billDetailBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
Log::info("Bill Detail data processing completed. Total processed: $processedCount, Total errors: $errorCount");
// Process any remaining records
if (!empty($this->billDetailBatch)) {
$this->saveBatch();
}
} catch (Exception $e) {
Log::error('Error in ProcessBillDetailDataJob: ' . $e->getMessage());
throw $e;
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
$this->errorCount++;
return;
}
$data = array_combine($headers, $row);
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(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 batch
$this->billDetailBatch[] = $data;
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Bill Detail at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->billDetailBatch)) {
// Process in smaller chunks for better memory management
foreach ($this->billDetailBatch 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
TempBillDetail::whereIn('_id', $entryIds)->delete();
// Insert all records in the chunk at once
TempBillDetail::insert($entry);
}
// Reset entry batch after processing
$this->billDetailBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->billDetailBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->billDetailBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Bill Detail data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

View File

@@ -16,14 +16,33 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.CATEGORY.csv';
private const DISK_NAME = 'sftpStatement';
private const HEADER_MAP = [
'id' => 'id_category',
'date_time' => 'date_time',
'description' => 'description',
'short_name' => 'short_name',
'system_ind' => 'system_ind',
'record_status' => 'record_status',
'co_code' => 'co_code',
'curr_no' => 'curr_no',
'l_db_cr_ind' => 'l_db_cr_ind',
'category_code' => 'category_code'
];
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
@@ -33,106 +52,151 @@
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for category data processing');
if ($this->period === '') {
Log::warning('No period provided for category data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.CATEGORY.csv";
$filePath = "$period/$filename";
Log::info("Processing category file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
// Get the headers from the first row
$headerRow = fgetcsv($handle, 0, "~");
// Map the headers to our model fields
$headerMap = [
'id' => 'id_category',
'date_time' => 'date_time',
'description' => 'description',
'short_name' => 'short_name',
'system_ind' => 'system_ind',
'record_status' => 'record_status',
'co_code' => 'co_code',
'curr_no' => 'curr_no',
'l_db_cr_ind' => 'l_db_cr_ind',
'category_code' => 'category_code'
];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headerRow) === count($row)) {
// Combine the header row with the data row
$rawData = array_combine($headerRow, $row);
// Map the raw data to our model fields
$data = [];
foreach ($headerMap as $csvField => $modelField) {
$data[$modelField] = $rawData[$csvField] ?? null;
}
try {
// Skip header row if it was included in the data
if ($data['id_category'] !== 'id') {
// Use firstOrNew instead of updateOrCreate
$category = Category::firstOrNew(['id_category' => $data['id_category']]);
$category->fill($data);
$category->save();
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Category at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headerRow) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Category data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessCategoryDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing category file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
// Get the headers from the first row
$headerRow = fgetcsv($handle, 0, self::CSV_DELIMITER);
if (!$headerRow) {
fclose($handle);
return;
}
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($headerRow, $row, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $headerRow, array $row, int $rowCount, string $filePath)
: void
{
if (count($headerRow) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headerRow) . ", Got: " . count($row));
return;
}
// Combine the header row with the data row
$rawData = array_combine($headerRow, $row);
$this->mapAndSaveRecord($rawData, $rowCount, $filePath);
}
private function mapAndSaveRecord(array $rawData, int $rowCount, string $filePath)
: void
{
// Map the raw data to our model fields
$data = [];
foreach (self::HEADER_MAP as $csvField => $modelField) {
$data[$modelField] = $rawData[$csvField] ?? null;
}
// Skip header row if it was included in the data
if ($data['id_category'] === 'id') {
return;
}
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
// Use firstOrNew instead of updateOrCreate
$category = Category::firstOrNew(['id_category' => $data['id_category']]);
$category->fill($data);
$category->save();
$this->processedCount++;
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Category at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Category data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -1,154 +1,209 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Basicdata\Models\Branch;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Basicdata\Models\Branch;
class ProcessCompanyDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
protected $filename;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [], string $filename = "ST.COMPANY.csv")
class ProcessCompanyDataJob implements ShouldQueue
{
$this->periods = $periods;
$this->filename = $filename;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.COMPANY.csv';
private const DISK_NAME = 'sftpStatement';
private const FIELD_MAP = [
'id' => null, // Not mapped to model
'date_time' => null, // Not mapped to model
'company_code' => 'code',
'company_name' => 'name',
'name_address' => 'address',
'mnemonic' => 'mnemonic',
'customer_company' => 'customer_company',
'customer_mnemonic' => 'customer_mnemonic',
'company_group' => 'company_group',
'curr_no' => 'curr_no',
'co_code' => 'co_code',
'l_vendor_atm' => 'l_vendor_atm',
'l_vendor_cpc' => 'l_vendor_cpc'
];
private const BOOLEAN_FIELDS = ['l_vendor_atm', 'l_vendor_cpc'];
if (empty($this->periods)) {
Log::warning('No periods provided for company data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No period provided for company data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessCompanyDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing company file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename = self::FILENAME)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
// Skip header row if it exists
if ($rowCount === 1 && (strtolower($row[0]) === 'id' || strtolower($row[2]) === 'company_code')) {
continue;
}
// Construct the filepath based on the period folder name
$fileName = "$period.$this->filename";
$filePath = "$period/$fileName";
$this->processRow($row, $rowCount, $filePath);
}
Log::info("Processing company file: $filePath");
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
$csvHeaders = array_keys(self::FIELD_MAP);
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_{$this->filename}");
file_put_contents($tempFilePath, $disk->get($filePath));
if (count($csvHeaders) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($csvHeaders) . ", Got: " . count($row));
return;
}
$handle = fopen($tempFilePath, "r");
$csvData = array_combine($csvHeaders, $row);
$this->mapAndSaveRecord($csvData, $rowCount, $filePath);
}
if ($handle !== false) {
// CSV headers from the file
$csvHeaders = [
'id', 'date_time', 'company_code', 'company_name', 'name_address',
'mnemonic', 'customer_company', 'customer_mnemonic', 'company_group',
'curr_no', 'co_code', 'l_vendor_atm', 'l_vendor_cpc'
];
// Field mapping from CSV to Branch model
$fieldMapping = [
'company_code' => 'code',
'company_name' => 'name',
'name_address' => 'address',
'mnemonic' => 'mnemonic',
'customer_company' => 'customer_company',
'customer_mnemonic' => 'customer_mnemonic',
'company_group' => 'company_group',
'curr_no' => 'curr_no',
'co_code' => 'co_code',
'l_vendor_atm' => 'l_vendor_atm',
'l_vendor_cpc' => 'l_vendor_cpc'
];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
// Skip header row if it exists
if ($rowCount === 1 && (strtolower($row[0]) === 'id' || strtolower($row[2]) === 'company_code')) {
continue;
}
if (count($csvHeaders) === count($row)) {
$csvData = array_combine($csvHeaders, $row);
// Map CSV data to Branch model fields
$branchData = [];
foreach ($fieldMapping as $csvField => $modelField) {
if (isset($csvData[$csvField])) {
// Convert string boolean values to actual booleans for boolean fields
if (in_array($modelField, ['l_vendor_atm', 'l_vendor_cpc'])) {
$branchData[$modelField] = filter_var($csvData[$csvField], FILTER_VALIDATE_BOOLEAN);
} else {
$branchData[$modelField] = $csvData[$csvField];
}
}
}
try {
if (!empty($branchData['code'])) {
Branch::updateOrCreate(
['code' => $branchData['code']],
$branchData
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Company data at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($csvHeaders) . ", Got: " . count($row));
}
private function mapAndSaveRecord(array $csvData, int $rowCount, string $filePath)
: void
{
// Map CSV data to Branch model fields
$branchData = [];
foreach (self::FIELD_MAP as $csvField => $modelField) {
if ($modelField !== null && isset($csvData[$csvField])) {
// Convert string boolean values to actual booleans for boolean fields
if (in_array($modelField, self::BOOLEAN_FIELDS)) {
$branchData[$modelField] = filter_var($csvData[$csvField], FILTER_VALIDATE_BOOLEAN);
} else {
$branchData[$modelField] = $csvData[$csvField];
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Company data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->saveRecord($branchData, $rowCount, $filePath);
}
} catch (Exception $e) {
Log::error('Error in ProcessCompanyDataJob: ' . $e->getMessage());
throw $e;
private function saveRecord(array $branchData, int $rowCount, string $filePath)
: void
{
try {
if (!empty($branchData['code'])) {
Branch::updateOrCreate(
['code' => $branchData['code']],
$branchData
);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Company data at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Company data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

View File

@@ -1,107 +1,216 @@
<?php
namespace Modules\Webstatement\Jobs;
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 Modules\Webstatement\Models\Customer;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Customer;
class ProcessCustomerDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
public function __construct(array $periods = [])
class ProcessCustomerDataJob implements ShouldQueue
{
$this->periods = $periods;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle()
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.CUSTOMER.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
if (empty($this->periods)) {
Log::warning('No periods provided for customer data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $customerBatch = [];
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No period provided for customer data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessCustomerDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->customerBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.CUSTOMER.csv";
$filePath = "$period/$filename";
Log::info("Processing customer file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new Customer())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if ($data['customer_code'] !== 'customer_code') {
$customer = Customer::firstOrNew(['customer_code' => $data['customer_code']]);
$customer->fill($data);
$customer->save();
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Customer at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing customer file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
Log::info("Customer data processing completed. Total processed: $processedCount, Total errors: $errorCount");
return true;
}
} catch (Exception $e) {
Log::error('Error in ProcessCustomerDataJob: ' . $e->getMessage());
throw $e;
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new Customer())->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->customerBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->customerBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['customer_code']) && $data['customer_code'] !== 'customer_code') {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to customer batch
$this->customerBatch[] = $data;
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Customer at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->customerBatch)) {
// Bulk insert/update customers
Customer::upsert(
$this->customerBatch,
['customer_code'], // Unique key
array_diff((new Customer())->getFillable(), ['customer_code']) // Update columns
);
// Reset customer batch after processing
$this->customerBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->customerBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->customerBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Customer data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

View File

@@ -16,16 +16,63 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
protected $filename;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.DATA.CAPTURE.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const CSV_HEADERS = [
'id',
'account_number',
'sign',
'amount_lcy',
'transaction_code',
'their_reference',
'narrative',
'pl_category',
'customer_id',
'account_officer',
'product_category',
'value_date',
'currency',
'amount_fcy',
'exchange_rate',
'neg_ref_no',
'position_type',
'our_reference',
'reversal_marker',
'exposure_date',
'currency_market',
'iblc_country',
'last_version',
'otor_version',
'department_code',
'dealer_desk',
'bank_sort_cde',
'cheque_number',
'accounting_date',
'contingent_acct',
'cheq_type',
'tfs_reference',
'accounting_company',
'stmt_no',
'curr_no',
'inputter',
'authoriser',
'co_code',
'date_time'
];
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $captureBatch = [];
/**
* Create a new job instance.
*/
public function __construct(array $periods = [], string $filename = "ST.DATA.CAPTURE.csv")
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->filename = $filename;
$this->period = $period;
}
/**
@@ -35,151 +82,180 @@
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for data capture processing');
if ($this->period === '') {
Log::warning('No period provided for data capture processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filepath based on the period folder name
$fileName = "$period.$this->filename";
$filePath = "$period/$fileName";
Log::info("Processing data capture file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_{$this->filename}");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
// CSV headers from the file
$csvHeaders = [
'id',
'account_number',
'sign',
'amount_lcy',
'transaction_code',
'their_reference',
'narrative',
'pl_category',
'customer_id',
'account_officer',
'product_category',
'value_date',
'currency',
'amount_fcy',
'exchange_rate',
'neg_ref_no',
'position_type',
'our_reference',
'reversal_marker',
'exposure_date',
'currency_market',
'iblc_country',
'last_version',
'otor_version',
'department_code',
'dealer_desk',
'bank_sort_cde',
'cheque_number',
'accounting_date',
'contingent_acct',
'cheq_type',
'tfs_reference',
'accounting_company',
'stmt_no',
'curr_no',
'inputter',
'authoriser',
'co_code',
'date_time'
];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
// Skip header row if it exists
if ($rowCount === 1 && strtolower($row[0]) === 'id') {
continue;
}
if (count($csvHeaders) === count($row)) {
$data = array_combine($csvHeaders, $row);
try {
// Format dates if they exist
foreach (['value_date', 'exposure_date', 'accounting_date'] as $dateField) {
if (!empty($data[$dateField])) {
try {
$data[$dateField] = date('Y-m-d', strtotime($data[$dateField]));
} catch (Exception $e) {
// If date parsing fails, keep the original value
Log::warning("Failed to parse date for $dateField: {$data[$dateField]}");
}
}
}
// Format datetime if it exists
if (!empty($data['date_time'])) {
try {
$data['date_time'] = date('Y-m-d H:i:s', strtotime($data['date_time']));
} catch (Exception $e) {
// If datetime parsing fails, keep the original value
Log::warning("Failed to parse datetime for date_time: {$data['date_time']}");
}
}
if (!empty($data['id'])) {
DataCapture::updateOrCreate(
['id' => $data['id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Data Capture at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($csvHeaders) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Data capture processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessDataCaptureDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->captureBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing data capture file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
// Skip header row if it exists
if ($rowCount === 1 && strtolower($row[0]) === 'id') {
continue;
}
$this->processRow($row, $rowCount, $filePath);
// Process in chunks to avoid memory issues
if (count($this->captureBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->captureBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
if (count(self::CSV_HEADERS) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count(self::CSV_HEADERS) . ", Got: " . count($row));
return;
}
$data = array_combine(self::CSV_HEADERS, $row);
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
if (!empty($data['id'])) {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to capture batch
$this->captureBatch[] = $data;
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Data Capture at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->captureBatch)) {
// Bulk insert/update data captures
DataCapture::upsert(
$this->captureBatch,
['id'], // Unique key
array_diff(self::CSV_HEADERS, ['id']) // Update columns
);
// Reset capture batch after processing
$this->captureBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->captureBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->captureBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Data capture processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -1,118 +1,175 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\FtTxnTypeCondition;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\FtTxnTypeCondition;
class ProcessFtTxnTypeConditionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
protected $filename;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [], string $filename = "ST.FT.TXN.TYPE.CONDITION.csv")
class ProcessFtTxnTypeConditionJob implements ShouldQueue
{
$this->periods = $periods;
$this->filename = $filename;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const EXPECTED_HEADERS = [
'id',
'date_time',
'transaction_type',
'short_descr',
'txn_code_cr',
'txn_code_dr'
];
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.FT.TXN.TYPE.CONDITION.csv';
private const DISK_NAME = 'sftpStatement';
if (empty($this->periods)) {
Log::warning('No periods provided for transaction type condition data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
public function __construct(string $period = '')
{
$this->period = $period;
}
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No periods provided for transaction type condition data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessFtTxnTypeConditionJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
/*if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}*/
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
// Construct the filepath based on the period folder name
$filePath = "$period/{$this->filename}";
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing transaction type condition file: $filePath");
Log::info("Processing transaction type condition file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_{$this->filename}");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = ['id', 'date_time', 'transaction_type', 'short_descr', 'txn_code_cr', 'txn_code_dr'];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
// Skip header row if it exists
if ($rowCount === 1 && strtolower($row[0]) === 'id') {
continue;
}
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (!empty($data['id'])) {
FtTxnTypeCondition::updateOrCreate(
['id' => $data['id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Transaction Type Condition at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
Log::info("Transaction type condition data processing completed. Total processed: $processedCount, Total errors: $errorCount");
return true;
}
} catch (Exception $e) {
Log::error('Error in ProcessFtTxnTypeConditionJob: ' . $e->getMessage());
throw $e;
private function createTemporaryFile($disk, string $filePath)
: string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
if ($this->isHeaderRow($rowCount, $row)) {
continue;
}
$this->processRow($row, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function isHeaderRow(int $rowCount, array $row)
: bool
{
return $rowCount === 1 && strtolower($row[0]) === 'id';
}
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
if (count(self::EXPECTED_HEADERS) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count(self::EXPECTED_HEADERS) . ", Got: " . count($row));
return;
}
$data = array_combine(self::EXPECTED_HEADERS, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (!empty($data['id'])) {
FtTxnTypeCondition::updateOrCreate(['id' => $data['id']], $data);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Transaction Type Condition at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Transaction type condition data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

View File

@@ -16,102 +16,158 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.FUNDS.TRANSFER.csv';
private const DISK_NAME = 'sftpStatement';
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle(): void
public function handle()
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for funds transfer data processing');
if ($this->period === '') {
Log::warning('No period provided for funds transfer data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.FUNDS.TRANSFER.csv";
$filePath = "$period/$filename";
Log::info("Processing funds transfer file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new TempFundsTransfer())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
// Handle case where row has more columns than headers
if (count($row) > count($headers)) {
$row = array_slice($row, 0, count($headers));
}
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (isset($data['_id']) && $data['_id'] !== '_id') {
TempFundsTransfer::updateOrCreate(
['_id' => $data['_id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Funds Transfer at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Funds Transfer data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessFundsTransferDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing funds transfer file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new TempFundsTransfer())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
// Handle case where row has more columns than headers
if (count($row) > count($headers)) {
$row = array_slice($row, 0, count($headers));
}
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['_id']) && $data['_id'] !== '_id') {
TempFundsTransfer::updateOrCreate(
['_id' => $data['_id']],
$data
);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Funds Transfer at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Funds Transfer data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Sector;
class ProcessSectorDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.SECTOR.csv';
private const DISK_NAME = 'sftpStatement';
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No periods provided for sector data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessSectorDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing sector file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath)
: string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = array_filter((new Sector())->getFillable(), function($field) {
return $field !== 'id';
});
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['sector_code']) && !empty($data['sector_code'])) {
Sector::updateOrCreate(['sector_code' => $data['sector_code'], 'co_code' => $data['co_code']], $data);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Sector at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Sector data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -1,5 +1,4 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
@@ -11,103 +10,246 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempStmtEntry;
use Illuminate\Support\Facades\DB;
class ProcessStmtEntryDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.STMT.ENTRY.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $entryBatch = [];
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle(): void
public function handle()
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for statement entry data processing');
if ($this->period === '') {
Log::warning('No period provided for statement entry data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}
// Construct the filename based on the period folder name
$filename = "$period.ST.STMT.ENTRY.csv";
$filePath = "$period/$filename";
Log::info("Processing statement entry file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new StmtEntry())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if ($data['stmt_entry_id'] !== 'stmt_entry_id') {
StmtEntry::updateOrCreate(
['stmt_entry_id' => $data['stmt_entry_id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Statement Entry at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Statement Entry data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessStmtEntryDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->entryBatch = [];
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing statement entry file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath, string $filename)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new StmtEntry())->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->entryBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->entryBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->cleanTransReference($data);
$this->addToBatch($data, $rowCount, $filePath);
}
private function cleanTransReference(array &$data)
: void
{
if (isset($data['trans_reference'])) {
// Clean trans_reference from \\BNK if present
$data['trans_reference'] = preg_replace('/\\\\.*$/', '', $data['trans_reference']);
}
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['stmt_entry_id']) && $data['stmt_entry_id'] !== 'stmt_entry_id') {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to entry batch
$this->entryBatch[] = $data;
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Statement Entry at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Simpan batch data ke database menggunakan updateOrCreate
* untuk menghindari error unique constraint
*
* @return void
*/
private function saveBatch(): void
{
Log::info('Memulai proses saveBatch dengan updateOrCreate');
DB::beginTransaction();
try {
if (!empty($this->entryBatch)) {
$totalProcessed = 0;
// Process each entry data directly (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
);
$totalProcessed++;
} else {
Log::warning('Invalid entry data structure', ['data' => $entryData]);
$this->errorCount++;
}
}
DB::commit();
Log::info("Berhasil memproses {$totalProcessed} record dengan updateOrCreate");
// Reset entry batch after successful processing
$this->entryBatch = [];
}
} catch (Exception $e) {
DB::rollback();
Log::error("Error in saveBatch: " . $e->getMessage() . "\n" . $e->getTraceAsString());
$this->errorCount += count($this->entryBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->entryBatch = [];
throw $e;
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Statement Entry data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -1,112 +1,164 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempStmtNarrFormat;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempStmtNarrFormat;
class ProcessStmtNarrFormatDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
class ProcessStmtNarrFormatDataJob implements ShouldQueue
{
$this->periods = $periods;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.STMT.NARR.FORMAT.csv';
private const DISK_NAME = 'sftpStatement';
if (empty($this->periods)) {
Log::warning('No periods provided for statement narrative format data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No periods provided for statement narrative format data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessStmtNarrFormatDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
/*if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}*/
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
// Construct the filename based on the period folder name
$filename = "ST.STMT.NARR.FORMAT.csv";
$filePath = "$period/$filename";
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing statement narrative format file: $filePath");
Log::info("Processing statement narrative format file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new TempStmtNarrFormat())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempStmtNarrFormat::updateOrCreate(
['_id' => $data['_id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Statement Narrative Format at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
Log::info("Statement Narrative Format data processing completed. Total processed: $processedCount, Total errors: $errorCount");
return true;
}
} catch (Exception $e) {
Log::error('Error in ProcessStmtNarrFormatDataJob: ' . $e->getMessage());
throw $e;
private function createTemporaryFile($disk, string $filePath)
: string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new TempStmtNarrFormat())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempStmtNarrFormat::updateOrCreate(['_id' => $data['_id']], $data);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Statement Narrative Format at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Statement Narrative Format data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

View File

@@ -16,97 +16,149 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.STMT.NARR.PARAM.csv';
private const DISK_NAME = 'sftpStatement';
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle(): void
public function handle()
: void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
$this->initializeJob();
if (empty($this->periods)) {
if ($this->period === '') {
Log::warning('No periods provided for statement narrative parameter data processing');
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
/*if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}*/
// Construct the filename based on the period folder name
$filename = "ST.STMT.NARR.PARAM.csv";
$filePath = "$period/$filename";
Log::info("Processing statement narrative parameter file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new TempStmtNarrParam())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempStmtNarrParam::updateOrCreate(
['_id' => $data['_id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Statement Narrative Parameter at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
}
Log::info("Statement Narrative Parameter data processing completed. Total processed: $processedCount, Total errors: $errorCount");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessStmtNarrParamDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing statement narrative parameter file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
return true;
}
private function createTemporaryFile($disk, string $filePath)
: string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new TempStmtNarrParam())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempStmtNarrParam::updateOrCreate(['_id' => $data['_id']], $data);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Statement Narrative Parameter at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Statement Narrative Parameter data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -16,13 +16,12 @@
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const PARAMETER_FOLDER = '_parameter';
// Konstanta untuk nilai-nilai statis
private const FILE_EXTENSION = '.ST.TELLER.csv';
private const CSV_DELIMITER = '~';
private const DISK_NAME = 'sftpStatement';
private const HEADER_MAP = [
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.TELLER.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const HEADER_MAP = [
'id' => 'id_teller',
'account_1' => 'account_1',
'currency_1' => 'currency_1',
@@ -125,18 +124,21 @@
'amount_fcy_2' => 'amount_fcy_2',
'rate_2' => 'rate_2',
'customer_1' => 'customer_1',
'last_version' => 'last_version'
'last_version' => 'last_version',
'dealer_desk' => 'dealer_desk',
];
// Pemetaan bidang header ke kolom model
protected array $periods;
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $tellerBatch = [];
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
public function __construct(string $period = '')
{
$this->periods = $periods;
$this->period = $period;
}
/**
@@ -146,148 +148,118 @@
: void
{
try {
set_time_limit(24 * 60 * 60);
$this->initializeJob();
if (empty($this->periods)) {
Log::warning('No periods provided for teller data processing');
if ($this->period === '') {
Log::warning('No period provided for teller data processing');
return;
}
$stats = $this->processPeriods();
Log::info("ProcessTellerDataJob completed. Total processed: {$stats['processed']}, Total errors: {$stats['errors']}");
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error("Error in ProcessTellerDataJob: " . $e->getMessage());
Log::error('Error in ProcessTellerDataJob: ' . $e->getMessage());
throw $e;
}
}
/**
* Process all periods and return statistics
*/
private function processPeriods()
: array
private function initializeJob()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$processedCount = 0;
$errorCount = 0;
foreach ($this->periods as $period) {
// Skip the parameter folder
if ($period === self::PARAMETER_FOLDER) {
Log::info("Skipping " . self::PARAMETER_FOLDER . " folder");
continue;
}
$result = $this->processPeriodFile($disk, $period);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
}
return [
'processed' => $processedCount,
'errors' => $errorCount
];
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->tellerBatch = [];
}
/**
* Process a single period file
*/
private function processPeriodFile($disk, string $period)
: array
private function processPeriod()
: void
{
$filename = $period . self::FILE_EXTENSION;
$filePath = "$period/$filename";
$processedCount = 0;
$errorCount = 0;
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing teller file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return ['processed' => 0, 'errors' => 0];
return false;
}
$tempFilePath = $this->createTempFile($disk, $filePath, $filename);
$result = $this->processCSVFile($tempFilePath, $filePath);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
// Clean up the temporary file
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
Log::info("Completed processing $filePath. Processed {$result['processed']} records with {$result['errors']} errors.");
return [
'processed' => $processedCount,
'errors' => $errorCount
];
return true;
}
/**
* Create a temporary file for processing
*/
private function createTempFile($disk, string $filePath, string $filename)
private function createTemporaryFile($disk, string $filePath, string $fileName)
: string
{
$tempFilePath = storage_path("app/temp_$filename");
$tempFilePath = storage_path("app/temp_$fileName");
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
/**
* Process a CSV file and import data
*/
private function processCSVFile(string $tempFilePath, string $originalFilePath)
: array
private function processFile(string $tempFilePath, string $filePath)
: void
{
$processedCount = 0;
$errorCount = 0;
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $originalFilePath");
return ['processed' => 0, 'errors' => 0];
Log::error("Unable to open file: $filePath");
return;
}
// Get the headers from the first row
$headerRow = fgetcsv($handle, 0, self::CSV_DELIMITER);
if (!$headerRow) {
fclose($handle);
return ['processed' => 0, 'errors' => 0];
return;
}
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($headerRow, $row, $rowCount, $filePath);
if (count($headerRow) !== count($row)) {
Log::warning("Row $rowCount in $originalFilePath has incorrect column count. Expected: " . count($headerRow) . ", Got: " . count($row));
continue;
// Process in chunks to avoid memory issues
if (count($this->tellerBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
$result = $this->processRow($headerRow, $row, $rowCount, $originalFilePath);
$processedCount += $result['processed'];
$errorCount += $result['errors'];
// Process any remaining records
if (!empty($this->tellerBatch)) {
$this->saveBatch();
}
fclose($handle);
return [
'processed' => $processedCount,
'errors' => $errorCount
];
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
/**
* Process a single row from the CSV file
*/
private function processRow(array $headerRow, array $row, int $rowCount, string $filePath)
: array
: void
{
// Skip if row doesn't have enough columns
if (count($headerRow) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headerRow) . ", Got: " . count($row));
$this->errorCount++;
return;
}
// Combine the header row with the data row
$rawData = array_combine($headerRow, $row);
@@ -299,18 +271,62 @@
// Skip header row if it was included in the data
if ($data['id_teller'] === 'id') {
return ['processed' => 0, 'errors' => 0];
return;
}
try {
$teller = Teller::firstOrNew(['id_teller' => $data['id_teller']]);
$teller->fill($data);
$teller->save();
// Add timestamps
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
return ['processed' => 1, 'errors' => 0];
// Add to batch for bulk processing
$this->tellerBatch[] = $data;
$this->processedCount++;
} catch (Exception $e) {
Log::error("Error processing Teller at row $rowCount in $filePath: " . $e->getMessage());
return ['processed' => 0, 'errors' => 1];
$this->errorCount++;
}
}
private function saveBatch(): void
{
try {
if (!empty($this->tellerBatch)) {
// Process in smaller chunks for better memory management
foreach ($this->tellerBatch as $entry) {
// Extract all stmt_entry_ids from the current chunk
$entryIds = array_column($entry, 'id_teller');
// Delete existing records with these IDs to avoid conflicts
Teller::whereIn('id_teller', $entryIds)->delete();
// Insert all records in the chunk at once
Teller::insert($entry);
}
// Reset entry batch after processing
$this->tellerBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->tellerBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->tellerBatch = [];
}
}
private function logJobCompletion()
: void
{
Log::info("ProcessTellerDataJob completed. Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
}

View File

@@ -1,112 +1,164 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempTransaction;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\TempTransaction;
class ProcessTransactionDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $periods;
/**
* Create a new job instance.
*/
public function __construct(array $periods = [])
class ProcessTransactionDataJob implements ShouldQueue
{
$this->periods = $periods;
}
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
try {
set_time_limit(24 * 60 * 60);
$disk = Storage::disk('sftpStatement');
$processedCount = 0;
$errorCount = 0;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.TRANSACTION.csv';
private const DISK_NAME = 'sftpStatement';
if (empty($this->periods)) {
Log::warning('No periods provided for transaction data processing');
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
/**
* Create a new job instance.
*/
public function __construct(string $period = '')
{
$this->period = $period;
}
/**
* Execute the job.
*/
public function handle()
: void
{
try {
$this->initializeJob();
if ($this->period === '') {
Log::warning('No periods provided for transaction data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
} catch (Exception $e) {
Log::error('Error in ProcessTransactionDataJob: ' . $e->getMessage());
throw $e;
}
}
private function initializeJob()
: void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
}
private function processPeriod()
: void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
if (!$this->validateFile($disk, $filePath)) {
return;
}
foreach ($this->periods as $period) {
// Skip the _parameter folder
/*if ($period === '_parameter') {
Log::info("Skipping _parameter folder");
continue;
}*/
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
// Construct the filename based on the period folder name
$filename = "ST.TRANSACTION.csv";
$filePath = "$period/$filename";
private function validateFile($disk, string $filePath)
: bool
{
Log::info("Processing transaction file: $filePath");
Log::info("Processing transaction file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
continue;
}
// Create a temporary local copy of the file
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
$handle = fopen($tempFilePath, "r");
if ($handle !== false) {
$headers = (new TempTransaction())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, "~")) !== false) {
$rowCount++;
if (count($headers) === count($row)) {
$data = array_combine($headers, $row);
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempTransaction::updateOrCreate(
['_id' => $data['_id']],
$data
);
$processedCount++;
}
} catch (Exception $e) {
$errorCount++;
Log::error("Error processing Transaction at row $rowCount in $filePath: " . $e->getMessage());
}
} else {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " . count($headers) . ", Got: " . count($row));
}
}
fclose($handle);
Log::info("Completed processing $filePath. Processed $processedCount records with $errorCount errors.");
// Clean up the temporary file
unlink($tempFilePath);
} else {
Log::error("Unable to open file: $filePath");
}
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
Log::info("Transaction data processing completed. Total processed: $processedCount, Total errors: $errorCount");
return true;
}
} catch (Exception $e) {
Log::error('Error in ProcessTransactionDataJob: ' . $e->getMessage());
throw $e;
private function createTemporaryFile($disk, string $filePath)
: string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
return $tempFilePath;
}
private function processFile(string $tempFilePath, string $filePath)
: void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new TempTransaction())->getFillable();
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
$this->saveRecord($data, $rowCount, $filePath);
}
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['_id']) && $data['_id'] !== 'id' && $data['_id'] !== '_id') {
TempTransaction::updateOrCreate(['_id' => $data['_id']], $data);
$this->processedCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Transaction at row $rowCount in $filePath: " . $e->getMessage());
}
}
private function cleanup(string $tempFilePath)
: void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
}
}
private function logJobCompletion()
: void
{
Log::info("Transaction data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}
}

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()
]);
}
}

147
app/Jobs/UnlockPdfJob.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
class UnlockPdfJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $baseDirectory;
protected $password;
/**
* Create a new job instance.
*
* @param string $baseDirectory Base directory path to scan
* @param string $password Password to unlock PDF files
*/
public function __construct(string $baseDirectory, string $password = '123456')
{
$this->baseDirectory = $baseDirectory;
$this->password = $password;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info("Starting PDF unlock process in directory: {$this->baseDirectory}");
// Check if directory exists
if (!File::isDirectory($this->baseDirectory)) {
Log::error("Directory not found: {$this->baseDirectory}");
return;
}
// Get all subdirectories (year folders like 202505)
$yearDirectories = File::directories($this->baseDirectory);
foreach ($yearDirectories as $yearDirectory) {
$this->processYearDirectory($yearDirectory);
}
Log::info("PDF unlock process completed successfully.");
} catch (Exception $e) {
Log::error("Error unlocking PDF files: " . $e->getMessage());
}
}
/**
* Process a year directory (e.g., 202505)
*
* @param string $yearDirectory Directory path to process
*/
protected function processYearDirectory(string $yearDirectory): void
{
try {
// Get all ID directories (e.g., ID0010001)
$idDirectories = File::directories($yearDirectory);
foreach ($idDirectories as $idDirectory) {
$this->processIdDirectory($idDirectory);
}
} catch (Exception $e) {
Log::error("Error processing year directory {$yearDirectory}: " . $e->getMessage());
}
}
/**
* Process an ID directory (e.g., ID0010001)
*
* @param string $idDirectory Directory path to process
*/
protected function processIdDirectory(string $idDirectory): void
{
try {
// Get all PDF files in the directory
$pdfFiles = File::glob($idDirectory . '/*.pdf');
foreach ($pdfFiles as $pdfFile) {
$this->unlockPdf($pdfFile);
}
} catch (Exception $e) {
Log::error("Error processing ID directory {$idDirectory}: " . $e->getMessage());
}
}
/**
* Unlock a password-protected PDF file
*
* @param string $pdfFilePath Path to PDF file
*/
protected function unlockPdf(string $pdfFilePath): void
{
try {
$filename = pathinfo($pdfFilePath, PATHINFO_FILENAME);
$directory = pathinfo($pdfFilePath, PATHINFO_DIRNAME);
$decryptedPdfPath = $directory . '/' . $filename . '.dec.pdf';
$finalPdfPath = $directory . '/' . $filename . '.pdf';
// Skip if the decrypted file already exists
if (File::exists($decryptedPdfPath)) {
Log::info("Decrypted file already exists: {$decryptedPdfPath}");
return;
}
// Create qpdf command
$command = ['qpdf', '--password=' . $this->password, '--decrypt', $pdfFilePath, $decryptedPdfPath];
// Execute the command
$process = new Process($command);
$process->run();
// Check if the command was successful
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
Log::info("Unlocked PDF: {$pdfFilePath} to {$decryptedPdfPath}");
// Remove the original encrypted file after successful decryption
if (File::exists($decryptedPdfPath)) {
// Delete the encrypted file
File::delete($pdfFilePath);
Log::info("Removed encrypted file: {$pdfFilePath}");
// Rename the decrypted file (remove .dec extension)
File::move($decryptedPdfPath, $finalPdfPath);
Log::info("Renamed decrypted file from {$decryptedPdfPath} to {$finalPdfPath}");
}
} catch (Exception $e) {
Log::error("Error unlocking PDF {$pdfFilePath}: " . $e->getMessage());
}
}
}

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

@@ -34,4 +34,23 @@ class Account extends Model
{
return $this->belongsTo(Customer::class, 'customer_code', 'customer_code');
}
/**
* Get all balances for this account.
*/
public function balances()
{
return $this->hasMany(AccountBalance::class, 'account_number', 'account_number');
}
/**
* Get balance for a specific period.
*
* @param string $period Format: YYYY-MM
* @return AccountBalance|null
*/
public function getBalanceForPeriod($period)
{
return $this->balances()->where('period', $period)->first();
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class AccountBalance extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'account_number',
'period',
'actual_balance',
'cleared_balance',
];
/**
* Get the account that owns the balance.
*/
public function account()
{
return $this->belongsTo(Account::class, 'account_number', 'account_number');
}
/**
* Scope a query to filter by account number.
*/
public function scopeForAccount($query, $accountNumber)
{
return $query->where('account_number', $accountNumber);
}
/**
* Scope a query to filter by period.
*/
public function scopeForPeriod($query, $period)
{
return $query->where('period', $period);
}
/**
* Get balance for a specific account and period.
*
* @param string $accountNumber
* @param string $period Format: YYYY-MM
* @return AccountBalance|null
*/
public static function getBalance($accountNumber, $period)
{
return self::where('account_number', $accountNumber)
->where('period', $period)
->first();
}
}

View File

@@ -30,16 +30,4 @@
'trans_status',
'proc_code',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'booking_date' => 'datetime',
'value_date' => 'datetime',
'txn_amount' => 'decimal:2',
'chrg_amount' => 'decimal:2',
];
}

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

@@ -21,7 +21,10 @@ class Customer extends Model
'postal_code',
'branch_code',
'date_of_birth',
'email'
'email',
'sector',
'customer_type',
'birth_incorp_date'
];
public function accounts(){

View File

@@ -61,14 +61,4 @@
'co_code',
'date_time'
];
protected $casts = [
'amount_lcy' => 'decimal:2',
'amount_fcy' => 'decimal:2',
'exchange_rate' => 'decimal:6',
'value_date' => 'date',
'exposure_date' => 'date',
'accounting_date' => 'date',
'date_time' => 'datetime'
];
}

View File

@@ -49,13 +49,4 @@
'txn_code_cr',
'txn_code_dr',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'date_time' => 'datetime',
];
}

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');
}
}

29
app/Models/Sector.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Sector extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'date_time',
'description',
'curr_no',
'co_code',
'sector_code'
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'date_time' => 'datetime',
];
}

View File

@@ -64,4 +64,16 @@ class StmtEntry extends Model
public function transaction(){
return $this->belongsTo(TempTransaction::class, 'transaction_code', 'transaction_code');
}
public function tt(){
return $this->belongsTo(Teller::class, 'trans_reference', 'id_teller');
}
public function dc(){
return $this->belongsTo(DataCapture::class, 'trans_reference', 'id');
}
public function aa(){
return $this->belongsTo(TempArrangement::class, 'trans_reference', 'arrangement_id');
}
}

View File

@@ -112,6 +112,7 @@
'amount_fcy_2',
'rate_2',
'customer_1',
'last_version'
'last_version',
'dealer_desk'
];
}

File diff suppressed because one or more lines are too long

View File

@@ -2,13 +2,23 @@
namespace Modules\Webstatement\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
use Nwidart\Modules\Traits\PathNamespace;
use Illuminate\Console\Scheduling\Schedule;
use Modules\Webstatement\Console\UnlockPdf;
use Modules\Webstatement\Console\CombinePdf;
use Modules\Webstatement\Console\ConvertHtmlToPdf;
use Modules\Webstatement\Console\ExportDailyStatements;
use Modules\Webstatement\Console\ProcessDailyMigration;
use Modules\Webstatement\Console\ExportPeriodStatements;
use Modules\Webstatement\Console\UpdateAllAtmCardsCommand;
use Modules\Webstatement\Console\CheckEmailProgressCommand;
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
use Modules\Webstatement\Console\SendStatementEmailCommand;
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
use Modules\Webstatement\Console\GenerateAtmTransactionReport;
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
class WebstatementServiceProvider extends ServiceProvider
{
@@ -52,7 +62,17 @@ class WebstatementServiceProvider extends ServiceProvider
{
$this->commands([
GenerateBiayakartuCommand::class,
GenerateBiayaKartuCsvCommand::class
GenerateBiayaKartuCsvCommand::class,
ProcessDailyMigration::class,
ExportDailyStatements::class,
CombinePdf::class,
ConvertHtmlToPdf::class,
UnlockPdf::class,
ExportPeriodStatements::class,
GenerateAtmTransactionReport::class,
SendStatementEmailCommand::class,
CheckEmailProgressCommand::class,
UpdateAllAtmCardsCommand::class
]);
}
@@ -82,6 +102,34 @@ class WebstatementServiceProvider extends ServiceProvider
->appendOutputTo(storage_path('logs/biaya-kartu-csv-scheduler.log'));
// Schedule the daily migration process to run at 1:00 AM (from previous task)
$schedule->command('webstatement:process-daily-migration')
->dailyAt('09:00')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/daily-migration.log'));
// Schedule the statement export to run at 2:00 AM (after migration is likely complete)
$schedule->command('webstatement:export-statements')
->dailyAt('09:30')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/statement-export.log'));
// Combine PDf
$schedule->command('webstatement:combine-pdf')
->dailyAt('09:30')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/combine-pdf.log'));
// Convert HTML to PDF
$schedule->command('webstatement:convert-html-to-pdf')
->dailyAt('09:30')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/convert-html-to-pdf.log'));
// Unlock PDF
$schedule->command('webstatement:unlock-pdf')
->dailyAt('09:30')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/unlock-pdf.log'));
}
/**

View File

@@ -0,0 +1,42 @@
<?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('account_balances', function (Blueprint $table) {
$table->id();
$table->string('account_number');
$table->string('period'); // Format: YYYY-MM
$table->string('actual_balance')->default(0);
$table->string('cleared_balance')->default(0);
$table->timestamps();
// Create a unique constraint to ensure one record per account per period
$table->unique(['account_number', 'period']);
// Add indexes for faster queries
$table->index('account_number');
$table->index('period');
$table->index('created_at');
// Add foreign key if needed
// $table->foreign('account_number')->references('account_number')->on('accounts');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('account_balances');
}
};

View File

@@ -0,0 +1,42 @@
<?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('account_balances', function (Blueprint $table) {
// First drop the unique constraint since we'll be making these columns the primary key
$table->dropUnique(['account_number', 'period']);
// Drop the id column and its auto-increment primary key
$table->dropColumn('id');
// Set the composite primary key
$table->primary(['account_number', 'period']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('account_balances', function (Blueprint $table) {
// Drop the composite primary key
$table->dropPrimary(['account_number', 'period']);
// Add back the id column with auto-increment
$table->id()->first();
// Re-add the unique constraint
$table->unique(['account_number', 'period']);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('processed_statements', function (Blueprint $table) {
// Drop the id column and its auto-increment primary key
$table->dropColumn('id');
// Set the composite primary key using account_number, period, and sequence_no
// This combination should be unique for each record
$table->primary(['account_number', 'period', 'sequence_no']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('processed_statements', function (Blueprint $table) {
// Drop the composite primary key
$table->dropPrimary(['account_number', 'period', 'sequence_no']);
// Add back the id column with auto-increment
$table->id()->first();
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change date_of_birth column from date to string type
*/
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
// First modify the column to string type
$table->string('date_of_birth')->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change date_of_birth column back to date type
*/
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
// Convert back to date type
$table->date('date_of_birth')->nullable()->change();
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change date fields to string type in accounts table
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
// Change opening_date from date to string
$table->string('opening_date')->nullable()->change();
// Change closure_date from date to string
$table->string('closure_date')->nullable()->change();
// Fix the start_year_bal column which has incorrect parameters
// First drop the column
$table->string('start_year_bal',255)->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change string fields back to date type
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
// Change opening_date back to date
$table->date('opening_date')->nullable()->change();
// Change closure_date back to date
$table->date('closure_date')->nullable()->change();
// Drop and recreate start_year_bal with original definition
$table->string('start_year_bal',15)->nullable()->change();
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change date_time column from dateTime to string type
*/
public function up(): void
{
Schema::table('ft_txn_type_condition', function (Blueprint $table) {
// Change date_time from dateTime to string
$table->string('date_time')->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change date_time column back to dateTime type
*/
public function down(): void
{
Schema::table('ft_txn_type_condition', function (Blueprint $table) {
// Change date_time back to dateTime
$table->dateTime('date_time')->nullable()->change();
});
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change date and dateTime fields to string type in data_captures table
*/
public function up(): void
{
Schema::table('data_captures', function (Blueprint $table) {
// Change date fields to string
$table->string('value_date')->nullable()->change();
$table->string('exposure_date')->nullable()->change();
$table->string('accounting_date')->nullable()->change();
// Change dateTime field to string
$table->string('date_time')->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change string fields back to date and dateTime types
*/
public function down(): void
{
Schema::table('data_captures', function (Blueprint $table) {
// Change string fields back to date
$table->date('value_date')->nullable()->change();
$table->date('exposure_date')->nullable()->change();
$table->date('accounting_date')->nullable()->change();
// Change string field back to dateTime
$table->dateTime('date_time')->nullable()->change();
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change decimal fields to string type in data_captures table
*/
public function up(): void
{
Schema::table('data_captures', function (Blueprint $table) {
// Change decimal fields to string
$table->string('amount_lcy')->nullable()->change();
$table->string('amount_fcy')->nullable()->change();
$table->string('exchange_rate')->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change string fields back to decimal types
*/
public function down(): void
{
Schema::table('data_captures', function (Blueprint $table) {
// Change string fields back to decimal with original precision and scale
$table->decimal('amount_lcy', 20, 2)->nullable()->change();
$table->decimal('amount_fcy', 20, 2)->nullable()->change();
$table->decimal('exchange_rate', 20, 6)->nullable()->change();
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Change date fields to string type in temp_arrangements table
*/
public function up(): void
{
Schema::table('temp_arrangements', function (Blueprint $table) {
// Change date fields to string
$table->string('orig_contract_date')->nullable()->change();
$table->string('start_date')->nullable()->change();
});
}
/**
* Reverse the migrations.
* Change string fields back to date type
*/
public function down(): void
{
Schema::table('temp_arrangements', function (Blueprint $table) {
// Change string fields back to date
$table->date('orig_contract_date')->nullable()->change();
$table->date('start_date')->nullable()->change();
});
}
};

View File

@@ -0,0 +1,50 @@
<?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('temp_funds_transfer', function (Blueprint $table) {
$table->text('at_unique_id')->nullable();
$table->text('bif_ref_no')->nullable();
$table->text('atm_order_id')->nullable();
$table->text('api_iss_acct')->nullable();
$table->text('api_benff_acct')->nullable();
$table->text('remarks')->nullable();
$table->text('api_mrchn_id')->nullable();
$table->text('bif_rcv_acct')->nullable();
$table->text('bif_snd_acct')->nullable();
$table->text('bif_rcv_name')->nullable();
$table->text('bif_va_no')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('temp_funds_transfer', function (Blueprint $table) {
$table->dropColumn([
'at_unique_id',
'bif_ref_no',
'atm_order_id',
'api_iss_acct',
'api_benff_acct',
'remarks',
'api_mrchn_id',
'bif_rcv_acct',
'bif_snd_acct',
'bif_rcv_name',
'bif_va_no'
]);
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('tellers', function (Blueprint $table) {
$table->string('dealer_desk')->nullable()->after('last_version');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tellers', function (Blueprint $table) {
$table->dropColumn('dealer_desk');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('stmt_entry', function (Blueprint $table) {
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('stmt_entry', function (Blueprint $table) {
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sectors', function (Blueprint $table) {
$table->id();
$table->dateTime('date_time');
$table->text('description');
$table->string('curr_no');
$table->string('co_code');
$table->string('sector_code');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sectors');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('sector')->nullable()->after('branch_code');
$table->string('customer_type')->nullable()->after('sector');
$table->string('birth_incorp_date')->nullable()->after('date_of_birth');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn(['sector', 'customer_type', 'birth_incorp_date']);
});
}
};

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

@@ -7,9 +7,13 @@ use Illuminate\Support\Facades\Route;
use Modules\Webstatement\Http\Controllers\JenisKartuController;
use Modules\Webstatement\Http\Controllers\KartuAtmController;
use Modules\Webstatement\Http\Controllers\MigrasiController;
use Modules\Webstatement\Http\Controllers\WebstatementController;
use Modules\Webstatement\Http\Controllers\CustomerController;
use Modules\Webstatement\Http\Controllers\EmailBlastController;
use Modules\Webstatement\Http\Controllers\WebstatementController;
use Modules\Webstatement\Http\Controllers\DebugStatementController;
use Modules\Webstatement\Http\Controllers\EmailStatementLogController;
use Modules\Webstatement\Http\Controllers\AtmTransactionReportController;
/*
@@ -87,10 +91,36 @@ 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');
Route::get('biaya-kartu', [SyncLogsController::class, 'index'])->name('biaya-kartu.index');
Route::get('/stmt-entries/{accountNumber}', [MigrasiController::class, 'getStmtEntryByAccount']);
Route::get('/', [WebstatementController::class, 'index'])->name('webstatement.index');
Route::get('/stmt-export-csv', [WebstatementController::class, 'index'])->name('webstatement.index');
Route::prefix('debug')->group(function () {
Route::get('/test-statement',[WebstatementController::class,'printStatementRekening'])->name('webstatement.test');
Route::post('/statement', [DebugStatementController::class, 'debugStatement'])->name('debug.statement');
Route::get('/statements', [DebugStatementController::class, 'listStatements'])->name('debug.statements.list');
});