Compare commits

...

46 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
50e60eb587 feat(statements): tampilkan End Date hanya untuk cabang 0988
- Bungkus input "End Date" dengan kondisi cabang: `@if (auth()->user()->branch->code === '0988') ... @endif`
- Batasi visibilitas field "End Date" agar hanya user cabang `0988` yang dapat melihat dan mengisi periode akhir (`period_to`)
- Pertahankan struktur input dan validasi yang ada: `type="month"`, binding `period_to`, dan blok `@error('period_to')` tetap berfungsi
- Pastikan tampilan form tetap rapi untuk cabang selain `0988` dengan field "End Date" disembunyikan tanpa mengganggu input lain
- Sinkronkan ekspektasi backend: field `period_to` hanya dikirim dari cabang `0988` sehingga controller/job perlu menangani nilai kosong untuk cabang lain
- Uji manual: login sebagai user cabang `0988` → pastikan field muncul; login cabang lain → pastikan field tidak muncul; validasi tetap berjalan
- Dokumentasikan batasan cabang dan alasan pembatasan untuk mencegah penggunaan tidak sesuai kebijakan internal
2025-12-01 11:00:07 +07:00
Daeng Deni Mardaeni
9373325399 feat(webstatement): dukung endPeriod dan format folder baru untuk statement
- Ubah konstruksi path SFTP dan storage lokal agar konsisten dengan format folder baru `YYYYMMDD.YYYYMMDD`
- Tambahkan dukungan periode akhir (`endPeriod`) pada alur cetak dan ekspor statement, lengkap dengan propagasi ke view dan log
- Perkuat logging di controller dan job untuk audit proses, serta sesuaikan penamaan file pada jalur PRINT

Rincian Perubahan
- PrintStatementController.php
  - Ganti path SFTP awal dari `{$period_from}/{$branch_code}/{$account_number}_{$period_from}.pdf` menjadi:
    - `{$periodPath}/PRINT/{$branch_code}/{$account_number}.1.pdf` pada cek file awal
    - Gunakan `$periodPath = formatPeriodForFolder($statement->period_from)` untuk semua referensi path
  - Iterasi ketersediaan periode:
    - Gunakan `formatPeriodForFolder($periodFormatted)` saat membentuk `periodPath` dalam loop bulan
  - Generate atau fetch statement:
    - Ubah path menjadi `{$periodPath}/{$branch_code}/{$account_number}_{$period}.pdf` untuk konsistensi
  - ZIP multi-periode:
    - Cari file ZIP pada `statements/{$periodPath}/multi_account/{$statementId}` sesuai format folder baru
  - Variabel periode:
    - Tambahkan `$endPeriod = $statement->period_to ?? $period` dan propagasikan ke:
      - Pemanggilan `generateStatementPdf($norek, $period, $endPeriod, ...)`
      - View `statements.stmt` melalui `compact(..., 'endPeriod')`
    - Perbarui logging untuk menampilkan `endPeriod`
  - Generate PDF:
    - Tandai storage path menjadi `statements/{$periodPath}/{$norek}`
    - Ubah signature: `generateStatementPdf($norek, $period, $endPeriod, ...)`
  - Akses file lokal/SFTP:
    - Ubah path storage menjadi `statements/{$periodPath}/{$account->branch_code}/{$filename}`
    - Penyesuaian delete path: `statements/{$periodPath}/{$norek}/{$filename}`

- ExportStatementPeriodJob.php
  - Tambah properti dan parameter konstruktor: `$endPeriod`
  - Ubah inisialisasi periode:
    - Ganti `calculatePeriodDates()` menjadi `formatPeriodForFolder()` (metode internal yang menetapkan `startDate` dan `endDate`)
    - Jika `$endPeriod` diisi, jadikan akhir bulan dari `endPeriod` sebagai `endDate` pemrosesan
  - Render view:
    - Tambahkan `endPeriod` ke `compact(...)` agar view mengetahui batas periode akhir
  - Storage path:
    - Gunakan `formatPeriodForFolder($this->period)` untuk path `statements/{$periodPath}/{$account->branch_code}`
  - Controller dispatch:
    - Ubah pemanggilan job menjadi `ExportStatementPeriodJob::dispatch($statementId, $accountNumber, $period, $endPeriod, $balance, $clientName)`

- resources/views/statements/stmt.blade.php
  - Periode:
    - Hitung `periodDates` via `calculatePeriodDates($period)`
    - Jika `endPeriod` ada, gunakan `calculatePeriodDates($endPeriod)` sebagai referensi `endDate`
  - Data customer:
    - Gunakan `$customer` langsung, bukan `$account->customer`
    - Kondisional alamat berdasarkan `stmt_sent_type == 'BY.MAIL.TO.DOM.ADDR'`:
      - Utamakan `l_dom_street` jika tersedia, fallback ke `address`
      - Susun RT/RW/kelurahan/kota/provinsi/kode pos sesuai preferensi pengiriman
  - Format angka:
    - Penyesuaian spasi dan casting `(float)` untuk konsistensi number_format
  - Logging:
    - Tambahkan informasi hasil perhitungan period dates untuk audit
2025-11-27 18:15:33 +07:00
Daeng Deni Mardaeni
ea23401473 feat(statement): perbaikan fitur statement dan penambahan akses sentra operasi
- Memberikan akses penuh fitur multi-branch untuk role `administrator` dan `sentra_operasi`.
- Menambahkan akun untuk client **SILOT** dalam daftar monitoring.
- Menonaktifkan validasi duplikasi statement di `PrintStatementRequest`.
- Memindahkan struktur penyimpanan file dari `statements/{client}` menjadi `partners/{client}`.
- Menambahkan pengurutan hasil berdasarkan `branch_code` dan `account_number` untuk laporan.
- Memperbaiki tampilan dropdown branch dan menyembunyikan field `end_date` yang tidak relevan.
- Menghapus opsi `NO.PRINT` dari dropdown `stmt_sent_type` untuk penyederhanaan UI.
- Peningkatan UI dan struktur direktori untuk mempermudah pembacaan dan pengelolaan statement.
2025-09-09 11:16:48 +07:00
Daeng Deni Mardaeni
5d0dbfcf21 🔄 refactor(jobs): perbaikan logika pada beberapa controller dan job
- **WebstatementController.php**:
  - Menyederhanakan fungsi `index()` dengan mengubah parameter menjadi langsung `string $queueName='default'`.
  - Menghapus pengambilan parameter `$queueName` dari objek `Request`.
- **ExportStatementPeriodJob.php**:
  - Memperbaiki perhitungan saldo berjalan (`running balance`) dengan mempertimbangkan mata uang.
  - Menambahkan logika penggunaan `amount_fcy` jika mata uang bukan IDR.
  - Menyesuaikan tipe transaksi (D/C) menggunakan nilai `amount` yang telah disesuaikan.
- **GenerateBiayaKartuCsvJob.php**:
  - Mengubah daftar produk yang dikecualikan menjadi `['6031','6021','6042']`.
  - Memperbaiki filter khusus dengan mengecualikan `product_code` 6004 jika `ctdesc` = CLASSIC.
  - Menambahkan kolom hash unik 16 digit pada data CSV untuk identifikasi setiap record.
- **ProcessCustomerDataJob.php**:
  - Menambahkan mapping baru `name_1` → `name` pada `getHeaderMapping`.
  - Menambahkan logging untuk field `fillable` agar debugging lebih mudah.
2025-09-09 08:51:53 +07:00
Daeng Deni Mardaeni
291e791114 feat(api): implementasi autentikasi HMAC dan validasi komprehensif untuk API balance
- Security: validasi HMAC SHA512 untuk semua request, cek timestamp ISO 8601 dengan toleransi 5 menit, autentikasi API key, dan wajib header X-Api-Key, X-Signature, X-Timestamp.
- Input validation: account_number numeric 10 digit & exists, start/end date format YYYY-MM-DD dengan aturan range (start ≤ end ≤ today).
- Perubahan file: update `app/Http/Requests/BalanceSummaryRequest.php` (HMAC check, timestamp check, pesan error, logging) dan `config/webstatement.php` (api_key, secret_key).
- Error handling: konsisten dengan ResponseCode enum; HTTP status 400/401/404; pesan error jelas (Bahasa Indonesia) + logging.
- Testing: Postman collection diperbarui untuk kasus negatif & edge cases; backward compatibility dijaga.
- Breaking changes: endpoint kini mewajibkan 3 header (X-Api-Key, X-Signature, X-Timestamp); account number wajib 10 digit numeric; format tanggal strict.
- ENV: tambahkan `WEBSTATEMENT_API_KEY` dan `WEBSTATEMENT_SECRET_KEY` (dipetakan ke `config/webstatement.php`).
2025-08-28 15:39:21 +07:00
Daeng Deni Mardaeni
00681a8e30 feat(auth): implementasi autentikasi HMAC dan standardisasi format respons API
- Tambah validasi HMAC (X-Signature, X-Timestamp, X-Api-Key) pada setiap request.
- Standarkan format respons sesuai ResponseCode; hapus `response_description` (gabung ke `response_message`).
- `BalanceSummaryRequest`: validasi header + `validateHmac512`, pakai secret dari config, logging detail, bedakan invalid API key vs invalid signature.
- `AccountBalanceController`: sederhanakan pesan error “Rekening tidak ditemukan”.
- Konfigurasi baru: `webstatement.api_key`, `webstatement.secret_key`; pastikan helper `validateHmac512` tersedia.
- Breaking: Bearer token tidak didukung; gunakan HMAC headers.
- Validasi nomor rekening di database sebelum proses bisnis.
- Logging terstruktur untuk setiap percobaan validasi HMAC (header & hasil verifikasi).
- Konsistensi kode error via ResponseCode enum untuk semua kasus gagal.
2025-08-28 13:44:58 +07:00
Daeng Deni Mardaeni
adda3122f8 feat(response): Restrukturisasi format response untuk validasi error dan response sukses
- Menambahkan logika baru di `ResponseCode` untuk memisahkan struktur response sukses dan error.
- Field `errors` sekarang berada di root level untuk response validasi error.
- Meta response error lebih ringkas, hanya menyertakan `generated_at` dan `request_id`.
- Pesan error menampilkan seluruh daftar error, bukan hanya error pertama.
- Konsistensi format response memudahkan integrasi dengan frontend.
- Field `account_number` dan `period` dihapus dari meta pada response error.
- Memberikan request_id otomatis pada setiap response untuk keperluan tracking.
- Semua endpoint yang menggunakan ResponseCode enum otomatis mengikuti format baru.
2025-08-28 11:18:57 +07:00
Daeng Deni Mardaeni
e53b522f77 feat(API): standarisasi response API dengan ResponseCode enum dan penambahan struktur meta
- Menambahkan ResponseCode enum untuk standarisasi semua response API.
- Integrasi meta data: nomor rekening, periode, request_id, dan reference_code.
- Memperbarui validasi input dengan response code standar (INVALID_FIELD).
- Struktur response dibuat konsisten untuk success dan error.
- Logging diperkuat untuk debugging dan monitoring.
2025-08-27 17:07:57 +07:00
Daeng Deni Mardaeni
ffdb528360 feat(api): menambahkan controller API untuk ringkasan saldo rekening
- Menambahkan `AccountBalanceController` dengan endpoint `GET /api/balance`.
- Integrasi dengan `AccountBalanceService` untuk pengelolaan logika bisnis.
- Validasi request menggunakan `BalanceSummaryRequest`.
- Formatting response dengan `BalanceSummaryResource`.
- Menyediakan ringkasan saldo, transaksi, dan metadata dengan response JSON konsisten.
2025-08-27 16:26:13 +07:00
Daeng Deni Mardaeni
1ff4035b98 feat(balance): implementasi service layer untuk balance management
- Menambahkan `AccountBalanceService` dengan transaksi PostgreSQL dan proper error handling.
- Implementasi `BalanceServiceProvider` untuk mendukung dependency injection pattern.
- Registrasi `BalanceServiceProvider` dalam `WebstatementServiceProvider`.
- Penambahan `CAST` ke `DECIMAL(15,2)` untuk kompatibilitas PostgreSQL.
- Perhitungan balance summary mencakup opening balance dan closing balance.
- Agregasi transaksi dengan type casting yang aman.
- Implementasi database transaction handling dengan mekanisme rollback dan commit.
- Logging komprehensif untuk debugging dan audit trail.
- Mendukung balance inquiry berdasarkan tanggal maupun periode tertentu.
- Validasi akun dengan pengecekan `exists` untuk memastikan data valid.
2025-08-27 16:22:06 +07:00
Daeng Deni Mardaeni
f324f9e3f6 feat(Modules/Webstatement): Tambahkan BalanceSummaryRequest dan BalanceSummaryResource untuk API Balance Summary
- Menambahkan BalanceSummaryRequest dengan validasi lengkap untuk parameter API balance summary.
- Menambahkan BalanceSummaryResource untuk transformasi dan formatting response balance summary.
- Implementasi validasi nomor rekening menggunakan regex alphanumeric dan dash.
- Validasi tanggal dengan format Y-m-d dan batasan logika tanggal.
- Format balance menggunakan number_format dengan pemisah ribuan dan desimal Indonesia (2, ',', '.').
- Struktur response JSON yang konsisten dengan informasi lengkap balance summary.
- Menambahkan metadata pada response untuk tracking dan debugging.
- Logging request untuk monitoring dan audit trail.
- Mendukung pagination dan filtering tanggal untuk API balance summary.
2025-08-27 16:19:06 +07:00
Daeng Deni Mardaeni
7af5bf2fe5 feat(customer): tambah field alamat lengkap & pemrosesan CSV dinamis
- Tambah 13 field alamat KTP & domisili di tabel customers (nullable, aman untuk rollback)
- Update model Customer: fillable & casting tanggal
- ProcessCustomerDataJob: header CSV dinamis, mapping otomatis, trim value
- Batch save pakai DB::transaction(), logging detail, error handling lengkap
- Fleksibel untuk CSV dengan header bervariasi & backward-compatible
2025-08-07 08:45:50 +07:00
Daeng Deni Mardaeni
8a6469ecc9 🔧 fix(job): perbaiki transaksi bersarang & optimasi batch processing di GenerateClosingBalanceReportJob
- Hapus transaksi bersarang:
  - Pindahkan DB::beginTransaction() & DB::commit() dari processAndSaveClosingBalanceData() ke handle() menggunakan DB::transaction()
  - Cegah error max_lock_per_transaction pada PostgreSQL

- Implementasi batch processing:
  - Tambah metode batchUpdateOrCreate()
  - Ambil data existing dalam satu query via whereIn()
  - Pisahkan data menjadi toInsert & toUpdate
  - Gunakan DB::table()->insert() untuk batch insert
  - Lakukan update individual untuk data yang sudah ada

- Penyederhanaan error handling:
  - Hapus try-catch di processAndSaveClosingBalanceData() karena sudah ditangani di handle()

- Penambahan logging:
  - Tambah log informasi untuk monitoring batch insert/update

- Optimasi performa:
  - Kurangi beban database & mencegah duplikasi data
  - Gunakan pendekatan "delete-first, then insert" seperti ExportStatementJob
2025-08-05 13:49:58 +07:00
Daeng Deni Mardaeni
aae0c4ab15 ♻️ refactor(GenerateClosingBalanceReportJob): Sederhanakan penanganan duplikasi sesuai pendekatan ExportStatementJob
- Menambahkan method `updateOrCreate` untuk penanganan duplikasi yang lebih efisien
- Menghapus method `deleteExistingProcessedDataWithVerification` dengan verifikasi kompleks
- Menghapus method `verifyNoDuplicatesAfterInsert` yang tidak diperlukan lagi
- Menghapus method `generateReportData` yang sudah tidak digunakan
- Menghapus method `buildReportDataRow` yang menjadi bagian dari method di atas
- Menghapus method `exportToCsv` yang sudah tidak relevan
- Menyederhanakan query penghapusan data dengan menghapus kondisi `group_name`
- Mengubah pendekatan dari insertOrIgnore menjadi updateOrCreate untuk penanganan duplikasi
- Menghapus kode yang tidak digunakan untuk mengurangi kompleksitas
2025-08-04 11:34:00 +07:00
Daeng Deni Mardaeni
150d52f8da refactor(GenerateClosingBalanceReportJob): Sederhanakan pendekatan eliminasi duplikasi seperti ExportStatementJob
- Menghapus logika duplikasi yang kompleks dengan unique_hash dan pengecekan duplikasi
- Menyederhanakan proses delete data existing seperti pada ExportStatementJob
- Menghapus lock table dan verifikasi duplikasi yang rumit
- Menggunakan pendekatan langsung: hapus data lama -> proses ulang -> insert baru
- Menghapus field unique_hash yang tidak diperlukan lagi
- Menyederhanakan query builder tanpa eliminasi duplicate yang kompleks

Perubahan ini mengikuti pola yang sudah terbukti berhasil pada ExportStatementJob,
yang tidak memiliki masalah duplikasi dengan pendekatan yang lebih sederhana.
2025-08-04 09:25:48 +07:00
Daeng Deni Mardaeni
8736ccf5f8 feat(closing-balance): Implementasi Pengecekan Unique Hash dan Sequence Counter pada Ekspor CSV
Memperbaiki logika ekspor CSV laporan penutupan saldo untuk memastikan konsistensi data dan urutan sequence yang benar.

Perubahan yang dilakukan:
• Menambahkan pengecekan unique_hash untuk mencegah duplikasi data dalam file CSV
• Mengimplementasikan array $processedHashes untuk melacak unique_hash yang sudah diproses
• Mengganti sequence_no dari database dengan counter yang bertambah ($sequenceCounter++)
• Menghapus groupBy('unique_hash') dari query untuk kontrol duplikasi yang lebih baik
• Menambahkan logging untuk record yang di-skip karena duplikasi unique_hash
• Menambahkan logging untuk tracking jumlah record yang diproses per chunk
• Menggunakan reference (&$sequenceCounter, &$processedHashes) untuk konsistensi data antar chunk
• Memastikan sequence number berurutan tanpa gap dimulai dari 1
2025-07-31 11:55:55 +07:00
Daeng Deni Mardaeni
710cbb5232 feat(closing-balance): implementasi unique_hash dan insertOrIgnore untuk eliminasi duplikasi
Perbaikan masalah duplikasi pada laporan penutupan saldo dengan pendekatan hash unik dan query insert yang toleran terhadap duplikasi.

Perubahan:
- Tambah kolom `unique_hash` pada tabel `processed_closing_balances` (via migrasi `2025_07_31_035159_add_unique_hash_field_to_processed_closing_balances_table.php`)
- Tambah field `unique_hash` ke `$fillable` pada model `ProcessedClosingBalance`
- Update logika generate unique key di `prepareProcessedClosingBalanceData()` menggunakan `md5(trans_reference + '_' + amount_lcy)`
- Query pencarian duplikasi berdasarkan `unique_hash`, bukan `trans_reference` saja
- Ganti `insert()` dengan `insertOrIgnore()` untuk mencegah error saat insert duplikat data

Dampak:
- Duplikasi data dihindari secara efektif lewat hash unik
- Tidak ada error meski data duplicate ditemukan, karena query otomatis mengabaikannya
- `trans_reference` yang sama tetap valid selama nilai `amount_lcy` berbeda
- Data laporan lebih konsisten dan terhindar dari konflik constraint
2025-07-31 11:06:11 +07:00
Daeng Deni Mardaeni
13e077073b fix(closing-balance): Perbaikan logika pengecekan duplikasi untuk memperbolehkan trans_reference duplikat dengan amount_lcy berbeda
- Mengubah kriteria pengecekan duplikasi dari hanya trans_reference menjadi kombinasi trans_reference + amount_lcy
- Memperbarui query existingReferences untuk memeriksa kombinasi trans_reference dan amount_lcy
- Memperbolehkan trans_reference yang sama selama amount_lcy berbeda value
- Menambahkan validasi yang lebih presisi untuk mencegah duplikasi data yang sebenarnya
- Mengurangi false positive pada pengecekan duplikasi
2025-07-31 10:35:05 +07:00
Daeng Deni Mardaeni
eff951c600 feat(webstatement): implementasi perbaikan duplikasi data dan optimasi GenerateClosingBalanceReportJob
Perubahan komprehensif pada GenerateClosingBalanceReportJob.php untuk mengatasi masalah duplikasi data dan meningkatkan performa:

**Perbaikan Duplikasi Data:**
- Menambahkan metode deleteExistingProcessedDataWithVerification() untuk penghapusan data yang lebih aman dengan verifikasi lengkap
- Implementasi verifyNoDuplicatesAfterInsert() untuk memastikan tidak ada duplikasi setelah insert data
- Penyederhanaan logika pengecekan duplikasi berdasarkan trans_reference dan amount_lcy saja, mengingat trans_reference bersifat unik secara global
- Perbaikan buildTransactionQuery() dengan eliminasi duplikasi yang lebih efektif menggunakan subquery

**Fitur Export CSV:**
- Menambahkan metode exportFromDatabaseToCsv() untuk export data langsung dari database ke CSV dengan performa tinggi
- Implementasi chunking untuk menangani dataset besar secara efisien
- Pengurutan data berdasarkan booking_date dan transaction_date untuk konsistensi output
- Struktur header CSV yang lengkap dengan 26 kolom sesuai kebutuhan laporan

**Utilitas Tambahan:**
- Menambahkan getProcessedRecordCount() untuk monitoring jumlah record yang diproses
- Implementasi getOpeningBalance() dengan logika penanganan periode sebelumnya
- Perbaikan handling untuk periode khusus (20250512) dengan pengurangan 2 hari
- Fallback ke saldo 0 jika account balance tidak ditemukan

**Optimasi Query:**
- Perbaikan eager loading pada relasi 'ft' dan 'dc' dengan select kolom spesifik
- Implementasi subquery untuk mendapatkan ID unik berdasarkan kombinasi trans_reference dan amount_lcy
- Penggunaan MIN(id) dan MIN(date_time) untuk konsistensi data

**Logging dan Monitoring:**
- Penambahan logging komprehensif di setiap tahap proses
- Monitoring ukuran file dan verifikasi keberhasilan export
- Warning log untuk kasus account balance tidak ditemukan
- Info log untuk tracking opening balance dan processed record count

**Perbaikan Teknis:**
- Fix syntax error pada import ShouldQueue (menambahkan semicolon yang hilang)
- Perbaikan indentasi dan formatting kode untuk konsistensi
- Penambahan spacing yang tepat antar metode

**Dampak:**
- Eliminasi duplikasi data pada tabel processed_closing_balances
- Peningkatan performa export dengan chunking dan direct database access
- Konsistensi data yang lebih baik dengan verifikasi berlapis
- Monitoring dan debugging yang lebih mudah dengan logging yang komprehensif
- Kemudahan maintenance dengan struktur kode yang lebih terorganisir

Perubahan ini memastikan integritas data, meningkatkan performa, dan memberikan monitoring yang lebih baik untuk proses generate closing balance report.
2025-07-31 10:19:50 +07:00
Daeng Deni Mardaeni
6ad5aff358 refactor(webstatement): Sederhanakan pengecekan duplicate pada GenerateClosingBalanceReportJobrefactor(webstatement): Sederhanakan pengecekan duplicate pada GenerateClosingBalanceReportJob
Menyederhanakan logika pengecekan duplicate berdasarkan karakteristik trans_reference yang sudah unique secara global, sehingga cukup menggunakan kombinasi trans_reference + amount_lcy tanpa perlu filter berdasarkan account_number dan period.

Perubahan:
- Simplifikasi verifyNoDuplicatesAfterInsert dengan pengecekan trans_reference + amount_lcy saja
- Perbaikan buildTransactionQuery dengan groupBy yang disederhanakan
- Menghapus groupBy booking_date karena trans_reference sudah unique
- Optimasi performa query dengan grouping yang lebih efisien
- Penyesuaian logging untuk mencerminkan logika yang disederhanakan
- Mempertahankan pengecekan sequence_no duplicate dalam scope yang tepat

Logika Bisnis:
- Trans_reference adalah unique identifier global
- Trans_reference hanya bisa duplicate jika amount_lcy berbeda
- Tidak perlu filter berdasarkan account_number/period untuk pengecekan duplicate
- Fokus pada kombinasi trans_reference + amount_lcy sebagai key uniqueness

Dampak:
- Query yang lebih efisien dan cepat
- Logika yang lebih sesuai dengan karakteristik data
- Maintenance code yang lebih mudah
- Performa duplicate detection yang lebih baik

Rekomendasi:
- Tambahkan unique constraint (trans_reference, amount_lcy) di database
- Monitor performa setelah simplifikasi
- Validasi hasil dengan data production

Menyederhanakan logika pengecekan duplicate berdasarkan karakteristik trans_reference yang sudah unique secara global, sehingga cukup menggunakan kombinasi trans_reference + amount_lcy tanpa perlu filter berdasarkan account_number dan period.

Perubahan:
- Simplifikasi verifyNoDuplicatesAfterInsert dengan pengecekan trans_reference + amount_lcy saja
- Perbaikan buildTransactionQuery dengan groupBy yang disederhanakan
- Menghapus groupBy booking_date karena trans_reference sudah unique
- Optimasi performa query dengan grouping yang lebih efisien
- Penyesuaian logging untuk mencerminkan logika yang disederhanakan
- Mempertahankan pengecekan sequence_no duplicate dalam scope yang tepat

Logika Bisnis:
- Trans_reference adalah unique identifier global
- Trans_reference hanya bisa duplicate jika amount_lcy berbeda
- Tidak perlu filter berdasarkan account_number/period untuk pengecekan duplicate
- Fokus pada kombinasi trans_reference + amount_lcy sebagai key uniqueness

Dampak:
- Query yang lebih efisien dan cepat
- Logika yang lebih sesuai dengan karakteristik data
- Maintenance code yang lebih mudah
- Performa duplicate detection yang lebih baik

Rekomendasi:
- Tambahkan unique constraint (trans_reference, amount_lcy) di database
- Monitor performa setelah simplifikasi
- Validasi hasil dengan data production
2025-07-31 10:08:23 +07:00
Daeng Deni Mardaeni
bd72eb7dfa fix(webstatement): perbaiki eliminasi duplicate pada GenerateClosingBalanceReportJob
Mengganti pendekatan eliminasi duplicate dari validasi di level aplikasi menjadi di level database untuk menangani kasus duplikat dengan sequence yang tidak berdekatan.

Perubahan yang dilakukan:
- Mengimplementasikan subquery untuk mendapatkan ID unik berdasarkan kombinasi `trans_reference`, `amount_lcy`, dan `booking_date`
- Menghapus validasi duplicate berbasis array `$seenTransactions` di level aplikasi
- Menggunakan `whereIn` untuk filter transaksi berdasarkan hasil subquery ID unik
- Menyederhanakan method `prepareProcessedClosingBalanceData` karena data sudah bersih dari duplicate
- Menambahkan logging jumlah transaksi unik untuk monitoring
- Mengurangi beban proses sejak awal untuk meningkatkan performa
2025-07-31 09:32:14 +07:00
Daeng Deni Mardaeni
8eb7e69b21 fix(jobs): eliminasi duplicate trans_reference dengan amount_lcy yang sama pada GenerateClosingBalanceReportJob
Memperbaiki bug yang menyebabkan data duplikat pada hasil closing balance report karena penggunaan `distinct` yang tidak efektif.

Perubahan utama:

• Query Optimization untuk Duplicate Elimination:
- Mengganti subquery `whereRaw` dengan pendekatan `groupBy` untuk performa dan akurasi yang lebih baik
- Menggunakan `MIN(id)` dan `MIN(date_time)` untuk menjaga konsistensi data terpilih
- Menambahkan `groupBy` pada `trans_reference`, `amount_lcy`, dan `booking_date` untuk filter duplikat yang akurat

• Double Layer Validation:
- Validasi di level database: eliminasi duplikat menggunakan `groupBy`
- Validasi di level aplikasi: menggunakan array `$seenTransactions` untuk deteksi duplikat saat iterasi
- Transaksi yang terdeteksi sebagai duplikat akan dilewati (skip processing)

• Enhanced Logging:
- Menambahkan log saat duplicate transaction terdeteksi dan dilewati
- Log jumlah total duplikat yang ditemukan untuk audit dan monitoring
- Menambahkan log jumlah record yang dihapus saat `deleteExistingProcessedData()`

• Database Index Recommendations:
- Menambahkan saran index untuk mendukung operasi `groupBy`:
  - `(account_number, booking_date, trans_reference, amount_lcy)`
- Index ini akan membantu efisiensi dalam filtering dan deduplikasi data

• Compatibility:
- Eager loading tetap dipertahankan untuk efisiensi query
- Skema output CSV tidak berubah (tidak ada breaking changes)
- Proses chunk tetap digunakan untuk efisiensi memory

Testing:
- Validasi output CSV tidak mengandung duplikat `trans_reference` dengan `amount_lcy` sama
- Performa tetap cepat (±5–10 menit)
- Validasi hasil closing balance tetap akurat dan konsisten

Breaking Changes: Tidak ada
Performa: Terjaga dengan eliminasi duplikat yang efektif
2025-07-31 09:07:28 +07:00
Daeng Deni Mardaeni
4ee5c2e419 perf(jobs): optimasi performa GenerateClosingBalanceReportJob untuk mengurangi waktu eksekusi
Melakukan refactor besar-besaran untuk meningkatkan efisiensi proses GenerateClosingBalanceReportJob yang sebelumnya memakan waktu hingga 1 jam per hari mutasi.

Perubahan utama:

• Eager Loading Implementation:
- Menambahkan eager loading pada relasi `ft` dan `dc` di `buildTransactionQuery()`
- Menggunakan `select` spesifik untuk mengambil hanya kolom yang diperlukan
- Menghindari N+1 query problem yang memperlambat proses signifikan

• Query Optimization:
- Mengganti `distinct()` dengan raw SQL berbasis subquery untuk kinerja lebih baik
- Menyederhanakan `orderBy` hanya pada `booking_date` dan `date_time`
- Memperkuat WHERE clause dengan subquery untuk filtering `trans_reference` dan `amount_lcy`

• Logging Optimization:
- Menghapus logging per transaksi pada `processTransactionData()`
- Menyisakan logging hanya per chunk untuk efisiensi monitoring
- Mengurangi overhead I/O log hingga 50%

• Chunk Size & Memory:
- Meningkatkan `chunk size` dari 1.000 → 5.000 record per iterasi
- Mengurangi overhead koneksi database dan iterasi array
- Menambahkan timeout 3600 detik dan memory limit yang lebih longgar
- Mengaktifkan mekanisme retry untuk job berat

• Indexing Recommendations:
- Menambahkan rekomendasi index:
  - `(account_number, booking_date)` untuk filtering awal
  - `(trans_reference, amount_lcy)` untuk deduplikasi
  - Index FK `ref_no`, `id` untuk relasi antar tabel

• Code Structure & Error Handling:
- Dokumentasi fungsi diperjelas
- Menambahkan penggunaan DB transaction pada critical section
- Flow processing data dirapikan dan lebih modular
2025-07-31 08:42:23 +07:00
Daeng Deni Mardaeni
ca92f32ccb feat(webstatement): tambah dukungan queue name pada StagingController dan WebstatementController
Menambahkan parameter queue_name untuk mengatur queue spesifik pada job processing:

- Menambahkan parameter queueName pada StagingController.processData() dan index()
- Menggunakan onQueue() method saat dispatch job di StagingController
- Memperbarui ProcessDailyStaging untuk mengirim queue_name ke controller
- Menambahkan parameter Request pada WebstatementController untuk menerima queue_name
- Menggunakan onQueue() method saat dispatch job di WebstatementController
- Menambahkan logging untuk queue_name di semua proses
- Memperbarui response JSON untuk menyertakan informasi queue_name
- Menambahkan komentar fungsi yang menjelaskan parameter queue_name
- Mempertahankan backward compatibility dengan default queue 'default'
- Meningkatkan fleksibilitas dalam manajemen queue untuk berbagai environment
- Memungkinkan pemisahan job berdasarkan prioritas atau resource yang tersedia
2025-07-31 08:13:02 +07:00
Daeng Deni Mardaeni
e1740c0850 refactor(webstatement): ubah ordering pada GenerateClosingBalanceReportJob
Mengubah ordering pada proses generate closing balance report agar hanya menggunakan booking_date dan date_time:

- Menghapus ordering berdasarkan trans_reference dan amount_lcy pada buildTransactionQuery()
- Menggunakan ordering booking_date dan date_time pada query transaksi
- Mengubah ordering pada exportFromDatabaseToCsv() dari sequence_no menjadi booking_date dan transaction_date
- Menambahkan komentar untuk menjelaskan perubahan ordering
- Mempertahankan distinct pada kombinasi trans_reference dan amount_lcy untuk menghindari duplikasi
- Memastikan konsistensi ordering antara proses penyimpanan dan export CSV
- Meningkatkan performa dengan ordering yang lebih sederhana dan relevan
2025-07-30 17:35:34 +07:00
Daeng Deni Mardaeni
d88f4a242e perf(webstatement): optimasi performa GenerateClosingBalanceReportJob dengan database staging
Perubahan yang dilakukan:
- Menambahkan model `ProcessedClosingBalance` untuk menyimpan data sementara laporan closing balance
- Membuat migration `processed_closing_balances` dengan 26 kolom dan index komposit untuk query optimal
- Mengganti proses langsung ekspor ke CSV menjadi dua tahap:
  * Tahap 1: Proses dan simpan data ke DB secara bertahap melalui `processAndSaveClosingBalanceData()`
  * Tahap 2: Ekspor data dari DB ke CSV via `exportFromDatabaseToCsv()`
- Menambahkan method:
  * `deleteExistingProcessedData()` untuk membersihkan data lama
  * `prepareProcessedClosingBalanceData()` untuk batch insert ke DB
  * `getProcessedRecordCount()` untuk monitoring progres
- Mengoptimalkan memori dengan menghindari akumulasi data dalam array
- Menambahkan DB transaction untuk menjamin konsistensi data selama proses
- Logging diperluas agar progres lebih mudah dipantau
- Menambahkan error handling untuk menangani kegagalan proses dengan aman

Keuntungan:
- Waktu proses menurun drastis dari 1+ jam menjadi beberapa menit
- Skalabilitas meningkat — mampu menangani jutaan record tanpa memory overload
- Data hasil olahan dapat diekspor ulang tanpa harus re-process
- Pola kerja selaras dengan `ExportStatementJob` untuk konsistensi antar modul
- Monitoring dan debugging lebih mudah melalui database dan log

Catatan tambahan:
- Tabel `processed_closing_balances` mendukung kolom `group_name` untuk segmentasi (QRIS/NON_QRIS)
- Menggunakan tipe data numerik dengan presisi untuk nilai keuangan (`amount_lcy`, `balance`, dsb)
2025-07-30 17:25:32 +07:00
Daeng Deni Mardaeni
c0e5ddd37a perf(webstatement): optimasi performa GenerateClosingBalanceReportJob dengan database staging
Perubahan yang dilakukan:
- Menambahkan model `ProcessedClosingBalance` untuk menyimpan data sementara laporan closing balance
- Membuat migration `processed_closing_balances` dengan 26 kolom dan index komposit untuk query optimal
- Mengganti proses langsung ekspor ke CSV menjadi dua tahap:
  * Tahap 1: Proses dan simpan data ke DB secara bertahap melalui `processAndSaveClosingBalanceData()`
  * Tahap 2: Ekspor data dari DB ke CSV via `exportFromDatabaseToCsv()`
- Menambahkan method:
  * `deleteExistingProcessedData()` untuk membersihkan data lama
  * `prepareProcessedClosingBalanceData()` untuk batch insert ke DB
  * `getProcessedRecordCount()` untuk monitoring progres
- Mengoptimalkan memori dengan menghindari akumulasi data dalam array
- Menambahkan DB transaction untuk menjamin konsistensi data selama proses
- Logging diperluas agar progres lebih mudah dipantau
- Menambahkan error handling untuk menangani kegagalan proses dengan aman

Keuntungan:
- Waktu proses menurun drastis dari 1+ jam menjadi beberapa menit
- Skalabilitas meningkat — mampu menangani jutaan record tanpa memory overload
- Data hasil olahan dapat diekspor ulang tanpa harus re-process
- Pola kerja selaras dengan `ExportStatementJob` untuk konsistensi antar modul
- Monitoring dan debugging lebih mudah melalui database dan log

Catatan tambahan:
- Tabel `processed_closing_balances` mendukung kolom `group_name` untuk segmentasi (QRIS/NON_QRIS)
- Menggunakan tipe data numerik dengan presisi untuk nilai keuangan (`amount_lcy`, `balance`, dsb)
2025-07-30 17:15:14 +07:00
Daeng Deni Mardaeni
5f9a82ec20 feat(console): tambah parameter queue_name pada ExportDailyStatements command
Menambahkan parameter queue_name untuk memberikan fleksibilitas dalam pemilihan queue saat menjalankan export daily statements:

- Menambahkan parameter queue_name dengan default value 'default' pada signature command
- Memperbarui description command untuk mencantumkan informasi queue name
- Menambahkan function-level comment pada class dan method handle sesuai standar
- Menambahkan import Log facade untuk logging yang konsisten
- Menambahkan logging di awal proses dengan informasi queue name dan command
- Memperbarui controller call untuk mengirim queue_name sebagai parameter
- Menambahkan output queue name pada info message untuk feedback user
- Menambahkan informasi queue pada job summary untuk transparansi
- Menambahkan logging sukses dengan detail job count dan queue name
- Memperbarui error logging untuk mencakup queue information
- Mempertahankan backward compatibility dengan parameter opsional
- Meningkatkan fleksibilitas dalam manajemen queue untuk proses export
- Memungkinkan penggunaan queue khusus untuk prioritas atau isolasi proses
- Meningkatkan observability dengan logging yang lebih komprehensif
2025-07-30 08:16:53 +07:00
Daeng Deni Mardaeni
33b1255dfb feat(console): tambah parameter queue_name pada ProcessDailyStaging command
Menambahkan parameter queue_name untuk memberikan fleksibilitas dalam pemilihan queue saat menjalankan proses staging:

- Menambahkan parameter queue_name dengan default value 'default' pada signature command
- Memperbarui description command untuk mencantumkan informasi queue name
- Menambahkan function-level comment pada class dan method handle sesuai standar
- Menambahkan queue_name ke semua log entries untuk tracking dan debugging yang lebih baik
- Menambahkan output queue name pada info message untuk feedback user
- Memperbarui controller call untuk mengirim queue_name sebagai parameter
- Menambahkan queue_name ke error logging untuk debugging yang lebih efektif
- Mempertahankan backward compatibility dengan parameter opsional
- Meningkatkan fleksibilitas dalam manajemen queue untuk proses staging
- Memungkinkan penggunaan queue khusus untuk prioritas atau isolasi proses
2025-07-30 08:01:49 +07:00
Daeng Deni Mardaeni
aff6039b33 feat(console): tambah parameter group pada GenerateClosingBalanceReportCommand
Perubahan yang dilakukan:
- Menambahkan parameter `group` sebagai argument wajib pada signature command
- Memperbarui deskripsi command untuk mencantumkan informasi tentang parameter `group`
- Menambahkan validasi `group` agar hanya menerima nilai yang diizinkan: `QRIS` dan `DEFAULT`
- Memperbarui method `validateParameters()` untuk mendukung validasi nilai `group`
- Memperbarui method `createReportLog()` agar menyimpan `group_name` ke database
- Menambahkan `group` ke semua entri log untuk keperluan tracking dan debugging
- Menyesuaikan pemanggilan `GenerateClosingBalanceReportJob` dengan menyertakan parameter `group`
- Menambahkan informasi `group` pada pesan output console untuk feedback pengguna
- Menjamin konsistensi dengan implementasi `GenerateClosingBalanceReportJob` yang telah mendukung parameter `group`
- Meningkatkan fleksibilitas command untuk mendukung multiple jenis transaksi

Manfaat:
- Memungkinkan generate laporan closing balance berdasarkan tipe transaksi
- Logging lebih informatif dan terstruktur berdasarkan kelompok transaksi
- Command lebih fleksibel dan extensible untuk kebutuhan selanjutnya
- Validasi ketat memastikan data yang diproses sesuai spesifikasi sistem

Refs: #closing-balance-refactor
2025-07-29 16:59:29 +07:00
Daeng Deni Mardaeni
51e432c74f refactor(jobs): optimasi query GenerateClosingBalanceReportJob dengan distinct trans_reference dan amount_lcy
Melakukan refactoring pada GenerateClosingBalanceReportJob untuk meningkatkan performa dan akurasi:

- Menghapus eager loading yang kompleks pada relasi 'ft' dan 'dc' untuk menyederhanakan query
- Menambahkan distinct(['trans_reference', 'amount_lcy']) pada level query untuk mencegah duplikasi data langsung dari database
- Menambahkan orderBy untuk trans_reference dan amount_lcy sebelum orderBy existing untuk konsistensi hasil
- Memperbaiki kondisi pada getTableNameByGroup() dari '===' menjadi '!==' untuk logika yang benar
- Menghapus method getSelectFields() yang tidak lagi digunakan setelah simplifikasi query
- Memperbarui log message untuk mencerminkan penggunaan distinct pada kombinasi trans_reference dan amount_lcy
- Meningkatkan efisiensi dengan mengurangi kompleksitas query dan menghindari pemrosesan data duplikat di level aplikasi
- Memastikan akurasi perhitungan running balance dengan mengeliminasi entri duplikat sejak awal

Perubahan ini menggantikan pendekatan sebelumnya yang menggunakan array $processedTransactions untuk skip duplikat di level aplikasi dengan solusi yang lebih efisien di level database.
2025-07-29 16:29:27 +07:00
Daeng Deni Mardaeni
9cdc7f9487 refactor(webstatement): Migrasi ke StagingController dan ubah storage disk ke local 'staging'
Perubahan yang dilakukan:
- Hapus file `MigrasiController.php` yang tidak lagi digunakan
- Ganti referensi controller dari `MigrasiController` menjadi `StagingController` di `ProcessDailyMigration.php`
- Update semua Job class untuk menggunakan disk `staging` menggantikan `sftpStatement`
- Ganti konstanta `DISK_NAME` di class berikut:
  * `ProcessAccountDataJob`
  * `ProcessArrangementDataJob`
  * `ProcessAtmTransactionJob`
  * `ProcessBillDetailDataJob`
  * `ProcessCategoryDataJob`
  * `ProcessCompanyDataJob`
  * `ProcessCustomerDataJob`
  * `ProcessDataCaptureDataJob`
  * `ProcessFtTxnTypeConditionJob`
  * `ProcessFundsTransferDataJob`
  * `ProcessProvinceDataJob`
- Komentari sementara `array_pop()` di `ProcessDataCaptureDataJob` untuk debugging
- Rapikan whitespace dan formatting di `GenerateClosingBalanceReportCommand`
- Sesuaikan konfigurasi storage agar menggunakan local filesystem (`disk: staging`)
- Konsolidasikan penamaan dan penggunaan disk untuk environment `staging`
- Hilangkan ketergantungan terhadap koneksi SFTP dalam proses development/staging

Manfaat:
- Mempercepat proses development dan debugging dengan akses file lokal
- Menyederhanakan konfigurasi untuk staging environment
- Meningkatkan konsistensi dan maintainability kode
- Mengurangi potensi error akibat koneksi eksternal (SFTP)
2025-07-28 16:00:45 +07:00
Daeng Deni Mardaeni
5752427297 refactor(report): konversi query raw SQL ke pure Eloquent ORM
Melakukan refactor besar pada job GenerateClosingBalanceReport untuk mengganti penggunaan raw SQL dan left join dengan implementasi Eloquent ORM penuh, guna meningkatkan maintainability, akurasi data, dan performa sistem.

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

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

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

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

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

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

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

Perubahan yang dilakukan:

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

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

Tujuan perubahan:

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

Perubahan yang dilakukan:

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

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

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

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

Tujuan perubahan:

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

Perubahan yang dilakukan:

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

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

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

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

Tujuan perubahan:

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

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

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

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

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

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

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

Tujuan perubahan:

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

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

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

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

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

Tujuan perubahan:

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,51 +1,88 @@
<?php
namespace Modules\Webstatement\Console;
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\WebstatementController;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Http\Controllers\WebstatementController;
class ExportDailyStatements extends Command
/**
* Console command untuk export daily statements
* Command ini dapat dijalankan secara manual atau dijadwalkan
*/
class ExportDailyStatements extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:export-statements
{--queue_name=default : Queue name untuk menjalankan export jobs (default: default)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export daily statements for all configured client accounts dengan queue name yang dapat dikustomisasi';
/**
* Execute the console command.
* Menjalankan proses export daily statements
*
* @return int
*/
public function handle()
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:export-statements';
$queueName = $this->option('queue_name');
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export daily statements for all configured client accounts';
// Log start of process
Log::info('Starting daily statement export process', [
'queue_name' => $queueName ?? 'default',
'command' => 'webstatement:export-statements'
]);
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Starting daily statement export process...');
$this->info('Starting daily statement export process...');
$this->info('Queue Name: ' . ($queueName ?? 'default'));
try {
$controller = app(WebstatementController::class);
$response = $controller->index();
try {
$controller = app(WebstatementController::class);
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message']);
// Pass queue name to controller if needed
// Jika controller membutuhkan queue name, bisa ditambahkan sebagai parameter
$response = $controller->index($queueName);
// Display summary of jobs queued
$jobCount = count($responseData['jobs'] ?? []);
$this->info("Successfully queued {$jobCount} statement export jobs");
$responseData = json_decode($response->getContent(), true);
$message = $responseData['message'] ?? 'Export process completed';
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error exporting statements: ' . $e->getMessage());
return Command::FAILURE;
}
$this->info($message);
// Display summary of jobs queued
$jobCount = count($responseData['jobs'] ?? []);
$this->info("Successfully queued {$jobCount} statement export jobs");
$this->info("Jobs dispatched to queue: {$queueName}");
// Log successful completion
Log::info('Daily statement export process completed successfully', [
'message' => $message,
'job_count' => $jobCount,
'queue_name' => $queueName ?? 'default'
]);
return Command::SUCCESS;
} catch (Exception $e) {
$errorMessage = 'Error exporting statements: ' . $e->getMessage();
$this->error($errorMessage);
// Log error with queue information
Log::error($errorMessage, [
'exception' => $e->getTraceAsString(),
'queue_name' => $queueName ?? 'default'
]);
return Command::FAILURE;
}
}
}

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
<?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,85 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\StagingController;
use Illuminate\Support\Facades\Log;
/**
* Console command untuk memproses data staging harian
* Command ini dapat dijalankan secara manual atau dijadwalkan
*/
class ProcessDailyStaging extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:process-daily-staging
{--process_parameter= : To process staging parameter true/false}
{--period= : Period to process (default: -1 day, format: Ymd or relative date)}
{--queue_name=default : Queue name untuk menjalankan job (default: default)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process data staging for the specified period (default: previous day) dengan queue name yang dapat dikustomisasi';
/**
* Execute the console command.
* Menjalankan proses staging data harian
*
* @return int
*/
public function handle()
{
$processParameter = $this->option('process_parameter');
$period = $this->option('period');
$queueName = $this->option('queue_name');
// Log start of process
Log::info('Starting daily data staging process', [
'process_parameter' => $processParameter ?? 'false',
'period' => $period ?? '-1 day',
'queue_name' => $queueName ?? 'default'
]);
$this->info('Starting daily data staging process...');
$this->info('Process Parameter: ' . ($processParameter ?? 'False'));
$this->info('Period: ' . ($period ?? '-1 day (default)'));
$this->info('Queue Name: ' . ($queueName ?? 'default'));
try {
$controller = app(StagingController::class);
// Pass queue name to controller if needed
// Jika controller membutuhkan queue name, bisa ditambahkan sebagai parameter
$response = $controller->index($processParameter, $period, $queueName);
$responseData = json_decode($response->getContent(), true);
$message = $responseData['message'] ?? 'Process completed';
$this->info($message);
Log::info('Daily staging process completed successfully', [
'message' => $message,
'queue_name' => $queueName ?? 'default'
]);
return Command::SUCCESS;
} catch (Exception $e) {
$errorMessage = 'Error processing daily staging: ' . $e->getMessage();
$this->error($errorMessage);
Log::error($errorMessage, [
'exception' => $e->getTraceAsString(),
'queue_name' => $queueName ?? 'default'
]);
return Command::FAILURE;
}
}
}

140
app/Enums/ResponseCode.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
namespace Modules\Webstatement\Enums;
use Illuminate\Support\Str;
/**
* Response Code Enum untuk standarisasi response API
*
* @category Enums
* @package Modules\Webstatement\Enums
*/
enum ResponseCode: string
{
// Success Codes
case SUCCESS = '00';
// Data Error Codes
case INVALID_FIELD = '01';
case MISSING_FIELD = '02';
case INVALID_FORMAT = '03';
case DATA_NOT_FOUND = '04';
case DUPLICATE_REQUEST = '05';
case ACCOUNT_ALREADY_EXISTS = '06';
case ACCOUNT_NOT_FOUND = '07';
// Auth Error Codes
case INVALID_TOKEN = '10';
case UNAUTHORIZED = '11';
// System Error Codes
case SYSTEM_MALFUNCTION = '96';
case TIMEOUT = '97';
case SERVICE_UNAVAILABLE = '98';
case GENERAL_ERROR = '99';
/**
* Mendapatkan pesan response berdasarkan kode
*
* @return string
*/
public function getMessage(): string
{
return match($this) {
self::SUCCESS => 'Success',
self::INVALID_FIELD => 'Invalid Field',
self::MISSING_FIELD => 'Missing Field',
self::INVALID_FORMAT => 'Invalid Format',
self::DATA_NOT_FOUND => 'Data Not Found',
self::DUPLICATE_REQUEST => 'Duplicate Request',
self::ACCOUNT_ALREADY_EXISTS => 'Account Already Exists',
self::ACCOUNT_NOT_FOUND => 'Account Not Found',
self::INVALID_TOKEN => 'Invalid Token',
self::UNAUTHORIZED => 'Unauthorized',
self::SYSTEM_MALFUNCTION => 'System Malfunction',
self::TIMEOUT => 'Timeout',
self::SERVICE_UNAVAILABLE => 'Service Unavailable',
self::GENERAL_ERROR => 'General Error',
};
}
/**
* Mendapatkan deskripsi response berdasarkan kode
*
* @return string
*/
public function getDescription(): string
{
return match($this) {
self::SUCCESS => 'Permintaan berhasil',
self::INVALID_FIELD => 'Field tertentu tidak sesuai aturan',
self::MISSING_FIELD => 'Field wajib tidak dikirim',
self::INVALID_FORMAT => 'Format salah',
self::DATA_NOT_FOUND => 'Data yang diminta tidak ditemukan',
self::DUPLICATE_REQUEST => 'Request ID sama, sudah pernah diproses',
self::ACCOUNT_ALREADY_EXISTS => 'Nomor rekening / username / email sudah terdaftar',
self::ACCOUNT_NOT_FOUND => 'Nomor rekening / akun tidak ditemukan',
self::INVALID_TOKEN => 'Token tidak valid',
self::UNAUTHORIZED => 'Tidak punya akses',
self::SYSTEM_MALFUNCTION => 'Gangguan teknis di server',
self::TIMEOUT => 'Request timeout',
self::SERVICE_UNAVAILABLE => 'Layanan tidak tersedia',
self::GENERAL_ERROR => 'Kesalahan umum',
};
}
/**
* Mendapatkan HTTP status code berdasarkan response code
*
* @return int
*/
public function getHttpStatus(): int
{
return match($this) {
self::SUCCESS => 200,
self::INVALID_FIELD,
self::MISSING_FIELD,
self::INVALID_FORMAT => 400,
self::DATA_NOT_FOUND,
self::ACCOUNT_NOT_FOUND => 404,
self::DUPLICATE_REQUEST,
self::ACCOUNT_ALREADY_EXISTS => 409,
self::INVALID_TOKEN,
self::UNAUTHORIZED => 401,
self::SYSTEM_MALFUNCTION,
self::GENERAL_ERROR => 500,
self::TIMEOUT => 408,
self::SERVICE_UNAVAILABLE => 503,
};
}
/**
* Membuat response array standar
*
* @param mixed $data
* @param string|null $message
* @return array
*/
public function toResponse($data = null, ?string $message = null): array
{
$response = [
'status' => $this->value == '00' ? true : false,
'response_code' => $this->value,
'response_message' => $this->getMessage() . ($message ? ' | ' . $message : ''),
];
if (isset($data['errors'])) {
$response['errors'] = $data['errors'];
} else {
$response['data'] = $data;
}
$response['meta'] = [
'generated_at' => now()->toDateTimeString(),
'request_id' => request()->header('X-Request-ID', uniqid('req_'))
];
return $response;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Modules\Webstatement\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Http\Requests\BalanceSummaryRequest;
use Modules\Webstatement\Http\Requests\DetailedBalanceRequest;
use Modules\Webstatement\Services\AccountBalanceService;
use Modules\Webstatement\Http\Resources\BalanceSummaryResource;
use Modules\Webstatement\Http\Resources\DetailedBalanceResource;
use Modules\Webstatement\Enums\ResponseCode;
use Exception;
class AccountBalanceController extends Controller
{
protected AccountBalanceService $accountBalanceService;
public function __construct(AccountBalanceService $accountBalanceService)
{
$this->accountBalanceService = $accountBalanceService;
}
/**
* Get account balance summary (opening and closing balance)
*
* @param BalanceSummaryRequest $request
* @return JsonResponse
*/
public function getBalanceSummary(BalanceSummaryRequest $request): JsonResponse
{
try {
$accountNumber = $request->input('account_number');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
Log::info('Account balance summary requested', [
'account_number' => $accountNumber,
'start_date' => $startDate,
'end_date' => $endDate,
'ip' => $request->ip(),
'user_agent' => $request->userAgent()
]);
$result = $this->accountBalanceService->getBalanceSummary(
$accountNumber,
$startDate,
$endDate
);
if (empty($result)) {
return response()->json(
ResponseCode::DATA_NOT_FOUND->toResponse(
null,
'Rekening tidak ditemukan'
),
ResponseCode::DATA_NOT_FOUND->getHttpStatus()
);
}
return response()->json(
ResponseCode::SUCCESS->toResponse(
(new BalanceSummaryResource($result))->toArray($request),
),
ResponseCode::SUCCESS->getHttpStatus()
);
} catch (Exception $e) {
Log::error('Error getting account balance summary', [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
$responseCode = match ($e->getCode()) {
404 => ResponseCode::DATA_NOT_FOUND,
401 => ResponseCode::UNAUTHORIZED,
403 => ResponseCode::UNAUTHORIZED,
408 => ResponseCode::TIMEOUT,
503 => ResponseCode::SERVICE_UNAVAILABLE,
400 => ResponseCode::INVALID_FIELD,
default => ResponseCode::SYSTEM_MALFUNCTION
};
return response()->json(
$responseCode->toResponse(
null,
config('app.debug') ? $e->getMessage() : 'Terjadi kesalahan sistem'
),
$responseCode->getHttpStatus()
);
}
}
}

View File

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

View File

@@ -1,127 +0,0 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use BadMethodCallException;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\{ProcessAccountDataJob,
ProcessArrangementDataJob,
ProcessAtmTransactionJob,
ProcessBillDetailDataJob,
ProcessCategoryDataJob,
ProcessCompanyDataJob,
ProcessCustomerDataJob,
ProcessDataCaptureDataJob,
ProcessFtTxnTypeConditionJob,
ProcessFundsTransferDataJob,
ProcessStmtEntryDataJob,
ProcessStmtNarrFormatDataJob,
ProcessStmtNarrParamDataJob,
ProcessTellerDataJob,
ProcessTransactionDataJob,
ProcessSectorDataJob,
ProcessProvinceDataJob};
class MigrasiController extends Controller
{
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,
'province' => ProcessProvinceDataJob::class
];
private const PARAMETER_PROCESSES = [
'transaction',
'stmtNarrParam',
'stmtNarrFormat',
'ftTxnTypeCondition',
'sector',
'province'
];
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.");
}
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 processing for period {$period} has been queued successfully"
]);
}
}

View File

@@ -31,7 +31,11 @@ ini_set('max_execution_time', 300000);
->get();
$branch = Branch::find(Auth::user()->branch_id);
$multiBranch = session('MULTI_BRANCH') ?? false;
$multiBranch = false;
if(Auth::user()->hasRole(['administrator','sentra_operasi'])){
$multiBranch = session('MULTI_BRANCH') ?? false;
}
return view('webstatement::statements.index', compact('branches', 'branch', 'multiBranch'));
}
@@ -168,7 +172,11 @@ ini_set('max_execution_time', 300000);
try {
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
// Convert period format from YYYYMM to YYYYMMDD.YYYYMMDD for folder path
$periodPath = formatPeriodForFolder($statement->period_from);
$filePath = "{$periodPath}/PRINT/{$statement->branch_code}/{$statement->account_number}.1.pdf";
// Log untuk debugging
Log::info('Checking SFTP file path', [
@@ -186,7 +194,8 @@ ini_set('max_execution_time', 300000);
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
$periodFolderPath = formatPeriodForFolder($periodFormatted);
$periodPath = $periodFolderPath . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
@@ -316,7 +325,8 @@ ini_set('max_execution_time', 300000);
// Generate or fetch the statement file
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
$periodPath = formatPeriodForFolder($statement->period_from);
$filePath = "{$periodPath}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
if ($statement->is_period_range && $statement->period_to) {
// Log: Memulai proses download period range
@@ -339,7 +349,8 @@ ini_set('max_execution_time', 300000);
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
$periodFolderPath = formatPeriodForFolder($periodFormatted);
$periodPath = $periodFolderPath . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
@@ -383,7 +394,8 @@ ini_set('max_execution_time', 300000);
// Add each available statement to the zip
foreach ($availablePeriods as $period) {
$periodFilePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$period}.pdf";
$periodFolderPath = formatPeriodForFolder($period);
$periodFilePath = "{$periodFolderPath}/{$statement->branch_code}/{$statement->account_number}_{$period}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
try {
@@ -512,7 +524,7 @@ ini_set('max_execution_time', 300000);
$query = PrintStatementLog::query();
$query->whereNotNull('user_id');
if (!Auth::user()->role === 'administrator') {
if (!Auth::user()->hasRole(['administrator','sentra_operasi'])) {
$query->where(function($q) {
$q->where('user_id', Auth::id())
->orWhere('branch_code', Auth::user()->branch->code);
@@ -668,7 +680,8 @@ ini_set('max_execution_time', 300000);
$localDisk = Storage::disk('local');
$sftpDisk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
$periodPath = formatPeriodForFolder($statement->period_from);
$filePath = "{$periodPath}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
/**
* Fungsi helper untuk mendapatkan file dari disk dengan prioritas local
@@ -718,7 +731,8 @@ ini_set('max_execution_time', 300000);
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = "{$periodFormatted}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
$periodFolderPath = formatPeriodForFolder($periodFormatted);
$periodPath = "{$periodFolderPath}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
$fileInfo = $getFileFromDisk($periodPath);
@@ -906,6 +920,7 @@ ini_set('max_execution_time', 300000);
$norek = $statement->account_number;
$period = $statement->period_from;
$endPeriod = $statement->period_to ?? $period;
$format='pdf';
// Generate nama file PDF
@@ -977,20 +992,21 @@ ini_set('max_execution_time', 300000);
Log::info('Statement data prepared successfully', [
'account_number' => $norek,
'period' => $period,
'endPeriod' => $endPeriod ?? $period,
'saldo_period' => $saldoPeriod,
'saldo_awal' => $saldoAwalBulan->actual_balance ?? 0,
'entries_count' => $stmtEntries->count()
]);
$periodDates = calculatePeriodDates($period);
$periodDates = formatPeriodForFolder($period);
// Jika format adalah PDF, generate PDF
if ($format === 'pdf') {
return $this->generateStatementPdf($norek, $period, $stmtEntries, $account, $customer, $headerTableBg, $branch, $saldoAwalBulan, $statement->id, $tempPath, $filename);
return $this->generateStatementPdf($norek, $period, $endPeriod, $stmtEntries, $account, $customer, $headerTableBg, $branch, $saldoAwalBulan, $statement->id, $tempPath, $filename);
}
// Default return HTML view
return view('webstatement::statements.stmt', compact('stmtEntries', 'account', 'customer', 'headerTableBg', 'branch', 'period', 'saldoAwalBulan'));
return view('webstatement::statements.stmt', compact('stmtEntries', 'account', 'customer', 'headerTableBg', 'branch', 'period', 'saldoAwalBulan', 'endPeriod'));
} catch (Exception $e) {
DB::rollBack();
@@ -1028,7 +1044,7 @@ ini_set('max_execution_time', 300000);
* @param object $saldoAwalBulan Data saldo awal
* @return \Illuminate\Http\Response
*/
protected function generateStatementPdf($norek, $period, $stmtEntries, $account, $customer, $headerTableBg, $branch, $saldoAwalBulan, $statementId, $tempPath, $filename)
protected function generateStatementPdf($norek, $period, $endPeriod, $stmtEntries, $account, $customer, $headerTableBg, $branch, $saldoAwalBulan, $statementId, $tempPath, $filename)
{
try {
DB::beginTransaction();
@@ -1036,6 +1052,7 @@ ini_set('max_execution_time', 300000);
Log::info('Starting PDF generation with storage', [
'account_number' => $norek,
'period' => $period,
'endPeriod' => $endPeriod ?? $period,
'user_id' => Auth::id()
]);
@@ -1047,10 +1064,12 @@ ini_set('max_execution_time', 300000);
'headerTableBg',
'branch',
'period',
'endPeriod',
'saldoAwalBulan'
))->render();
// Tentukan path storage
$storagePath = "statements/{$period}/{$norek}";
// Tentukan path storage dengan format folder baru
$periodPath = formatPeriodForFolder($period);
$storagePath = "statements/{$periodPath}/{$norek}";
$fullStoragePath = "{$storagePath}/{$filename}";
// Generate PDF menggunakan Browsershot dan simpan langsung ke storage
@@ -1226,7 +1245,8 @@ ini_set('max_execution_time', 300000);
$account = Account::where('account_number',$norek)->first();
$storagePath = "statements/{$period}/{$account->branch_code}/{$filename}";
$periodPath = formatPeriodForFolder($period);
$storagePath = "statements/{$periodPath}/{$account->branch_code}/{$filename}";
// Cek apakah file ada di storage
if (!Storage::disk('local')->exists($storagePath)) {
@@ -1285,7 +1305,8 @@ ini_set('max_execution_time', 300000);
$filename = $this->generatePdfFileName($norek, $period);
}
$storagePath = "statements/{$period}/{$norek}/{$filename}";
$periodPath = formatPeriodForFolder($period);
$storagePath = "statements/{$periodPath}/{$norek}/{$filename}";
if (Storage::disk('local')->exists($storagePath)) {
$deleted = Storage::disk('local')->delete($storagePath);
@@ -1519,6 +1540,8 @@ ini_set('max_execution_time', 300000);
$accountNumber = $statement->account_number;
$period = $statement->period_from ?? date('Ym');
$endPeriod = $statement->period_to ?? $period;
$balance = AccountBalance::where('account_number', $accountNumber)
->when($period === '202505', function($query) {
return $query->where('period', '>=', '20250512')
@@ -1542,7 +1565,7 @@ ini_set('max_execution_time', 300000);
}
// Dispatch the job
$job = ExportStatementPeriodJob::dispatch($statement->id, $accountNumber, $period, $balance, $clientName);
$job = ExportStatementPeriodJob::dispatch($statement->id, $accountNumber, $period, $endPeriod, $balance, $clientName);
Log::info("Statement export job dispatched successfully", [
'job_id' => $job->job_id ?? null,
@@ -1602,8 +1625,9 @@ ini_set('max_execution_time', 300000);
], 404);
}
// Find ZIP file
$zipFiles = Storage::disk('local')->files("statements/{$statement->period_from}/multi_account/{$statementId}");
// Find ZIP file dengan format folder baru
$periodPath = formatPeriodForFolder($statement->period_from);
$zipFiles = Storage::disk('local')->files("statements/{$periodPath}/multi_account/{$statementId}");
$zipFile = null;
foreach ($zipFiles as $file) {

View File

@@ -0,0 +1,240 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use BadMethodCallException;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\{ProcessAccountDataJob,
ProcessArrangementDataJob,
ProcessAtmTransactionJob,
ProcessBillDetailDataJob,
ProcessCategoryDataJob,
ProcessCompanyDataJob,
ProcessCustomerDataJob,
ProcessDataCaptureDataJob,
ProcessFtTxnTypeConditionJob,
ProcessFundsTransferDataJob,
ProcessStmtEntryDataJob,
ProcessStmtNarrFormatDataJob,
ProcessStmtNarrParamDataJob,
ProcessTellerDataJob,
ProcessTransactionDataJob,
ProcessSectorDataJob,
ProcessProvinceDataJob,
ProcessStmtEntryDetailDataJob};
class StagingController extends Controller
{
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,
'stmtEntryDetail' => ProcessStmtEntryDetailDataJob::class,
'dataCapture' => ProcessDataCaptureDataJob::class,
'fundsTransfer' => ProcessFundsTransferDataJob::class,
'teller' => ProcessTellerDataJob::class,
'atmTransaction' => ProcessAtmTransactionJob::class,
'arrangement' => ProcessArrangementDataJob::class,
'billDetail' => ProcessBillDetailDataJob::class,
'sector' => ProcessSectorDataJob::class,
'province' => ProcessProvinceDataJob::class
];
private const PARAMETER_PROCESSES = [
'transaction',
'stmtNarrParam',
'stmtNarrFormat',
'ftTxnTypeCondition',
'sector',
'province'
];
private const DATA_PROCESSES = [
'category',
'company',
'customer',
'account',
'stmtEntry',
'stmtEntryDetail',
'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] ?? '', $parameters[1] ?? 'default');
}
}
throw new BadMethodCallException("Method {$method} does not exist.");
}
/**
* Memproses data dengan queue name yang dapat dikustomisasi
*
* @param string $type Tipe proses yang akan dijalankan
* @param string $period Periode data yang akan diproses
* @param string $queueName Nama queue untuk menjalankan job
* @return JsonResponse
*/
private function processData(string $type, string $period, string $queueName = 'default'): JsonResponse
{
try {
$jobClass = self::PROCESS_TYPES[$type];
// Dispatch job dengan queue name yang spesifik
$jobClass::dispatch($period)->onQueue($queueName);
$message = sprintf('%s data processing job has been queued successfully on queue: %s', ucfirst($type), $queueName);
Log::info($message, [
'type' => $type,
'period' => $period,
'queue_name' => $queueName
]);
return response()->json([
'message' => $message,
'queue_name' => $queueName
]);
} catch (Exception $e) {
Log::error(sprintf('Error in %s processing: %s', $type, $e->getMessage()), [
'type' => $type,
'period' => $period,
'queue_name' => $queueName,
'error' => $e->getMessage()
]);
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Proses migrasi data dengan parameter, periode, dan queue name yang dapat dikustomisasi
*
* @param bool|string $processParameter Flag untuk memproses parameter
* @param string|null $period Periode yang akan diproses (default: -1 day)
* @param string $queueName Nama queue untuk menjalankan job (default: default)
* @return JsonResponse
*/
public function index($processParameter = false, $period = null, $queueName = 'default')
{
try {
Log::info('Starting migration process', [
'process_parameter' => $processParameter,
'period' => $period,
'queue_name' => $queueName
]);
$disk = Storage::disk('staging');
if ($processParameter) {
Log::info('Processing parameter data', ['queue_name' => $queueName]);
foreach (self::PARAMETER_PROCESSES as $process) {
$this->processData($process, '_parameter', $queueName);
}
Log::info('Parameter processes completed successfully', ['queue_name' => $queueName]);
return response()->json([
'message' => 'Parameter processes completed successfully',
'queue_name' => $queueName
]);
}
// Tentukan periode yang akan diproses
$targetPeriod = $this->determinePeriod($period);
Log::info('Processing data for period', [
'period' => $targetPeriod,
'queue_name' => $queueName
]);
if (!$disk->exists($targetPeriod)) {
$errorMessage = "Period {$targetPeriod} folder not found in SFTP storage";
Log::warning($errorMessage, ['queue_name' => $queueName]);
return response()->json([
"message" => $errorMessage,
'queue_name' => $queueName
], 404);
}
foreach (self::DATA_PROCESSES as $process) {
$this->processData($process, $targetPeriod, $queueName);
}
$successMessage = "Data processing for period {$targetPeriod} has been queued successfully on queue: {$queueName}";
Log::info($successMessage, [
'period' => $targetPeriod,
'queue_name' => $queueName
]);
return response()->json([
'message' => $successMessage,
'queue_name' => $queueName
]);
} catch (Exception $e) {
Log::error('Error in migration index method: ' . $e->getMessage(), [
'queue_name' => $queueName,
'error' => $e->getMessage()
]);
return response()->json([
'error' => $e->getMessage(),
'queue_name' => $queueName
], 500);
}
}
/**
* Tentukan periode berdasarkan input atau gunakan default
*
* @param string|null $period Input periode
* @return string Periode dalam format Ymd
*/
private function determinePeriod($period = null): string
{
if ($period === null) {
// Default: -1 day
$calculatedPeriod = date('Ymd', strtotime('-1 day'));
Log::info('Using default period', ['period' => $calculatedPeriod]);
return $calculatedPeriod;
}
// Jika periode sudah dalam format Ymd (8 digit)
if (preg_match('/^\d{8}$/', $period)) {
Log::info('Using provided period in Ymd format', ['period' => $period]);
return $period;
}
// Jika periode dalam format relative date (contoh: -2 days, -1 week, etc.)
try {
$calculatedPeriod = date('Ymd', strtotime($period));
Log::info('Calculated period from relative date', [
'input' => $period,
'calculated' => $calculatedPeriod
]);
return $calculatedPeriod;
} catch (Exception $e) {
Log::warning('Invalid period format, using default', [
'input' => $period,
'error' => $e->getMessage()
]);
return date('Ymd', strtotime('-1 day'));
}
}
}

View File

@@ -1,198 +1,247 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Modules\Webstatement\Jobs\ExportStatementJob;
use Modules\Webstatement\Models\AccountBalance;
use Modules\Webstatement\Jobs\ExportStatementPeriodJob;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\Request;
use Modules\Webstatement\Jobs\ExportStatementJob;
use Modules\Webstatement\Models\AccountBalance;
use Modules\Webstatement\Jobs\ExportStatementPeriodJob;
use Illuminate\Support\Facades\Log;
class WebstatementController extends Controller
class WebstatementController extends Controller
{
/**
* Display a listing of the resource.
* Menjalankan export statement untuk semua akun dengan queue name yang dapat dikustomisasi
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function index(string $queueName='default')
{
/**
* Display a listing of the resource.
*/
public function index()
{
$jobIds = [];
$data = [];
Log::info('Starting statement export process', [
'queue_name' => $queueName
]);
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
];
}
$jobIds = [];
$data = [];
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
);
// Dispatch job dengan queue name yang spesifik
$jobIds[] = app(Dispatcher::class)->dispatch($job->onQueue($queueName));
$data[] = [
'client_name' => $clientName,
'account_number' => $accountNumber,
'period' => $period,
'queue_name' => $queueName
];
}
}
}
Log::info('Statement export jobs queued successfully', [
'total_jobs' => count($jobIds),
'queue_name' => $queueName
]);
return response()->json([
'message' => 'Statement export jobs have been queued',
'queue_name' => $queueName,
'jobs' => array_map(function ($index, $jobId) use ($data) {
return [
'job_id' => $jobId,
'client_name' => $data[$index]['client_name'],
'account_number' => $data[$index]['account_number'],
'period' => $data[$index]['period'],
'queue_name' => $data[$index]['queue_name'],
'file_name' => "{$data[$index]['client_name']}_{$data[$index]['account_number']}_{$data[$index]['period']}.csv"
];
}, array_keys($jobIds), $jobIds)
]);
}
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',
],
"AWAN_LINTANG_SOLUSI"=> [
"1084269430"
],
"MONETA"=> [
"1085667890"
],
"SILOT" => [
"1083972676"
]
];
}
function listPeriod(){
return [
date('Ymd', strtotime('-1 day'))
];
}
function getAccountBalance($accountNumber, $period)
{
$accountBalance = AccountBalance::where('account_number', $accountNumber)
->where('period', '<', $period)
->orderBy('period', 'desc')
->first();
return $accountBalance->actual_balance ?? 0;
}
/**
* Print statement rekening dengan queue name yang dapat dikustomisasi
*
* @param string $accountNumber
* @param string|null $period
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
function printStatementRekening($accountNumber, $period = null, Request $request = null) {
$queueName = $request ? $request->get('queue_name', 'default') : 'default';
$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';
try {
Log::info("Starting statement export for account: {$accountNumber}, period: {$period}, client: {$clientName}", [
'account_number' => $accountNumber,
'period' => $period,
'client_name' => $clientName,
'queue_name' => $queueName
]);
// Validate inputs
if (empty($accountNumber) || empty($period) || empty($clientName)) {
throw new \Exception('Required parameters missing');
}
// Dispatch the job dengan queue name yang spesifik
$job = ExportStatementPeriodJob::dispatch($accountNumber, $period, $balance, $clientName)
->onQueue($queueName);
Log::info("Statement export job dispatched successfully", [
'job_id' => $job->job_id ?? null,
'account' => $accountNumber,
'period' => $period,
'client' => $clientName,
'queue_name' => $queueName
]);
return response()->json([
'message' => 'Statement export jobs have been queued',
'jobs' => array_map(function ($index, $jobId) use ($data) {
return [
'job_id' => $jobId,
'client_name' => $data[$index]['client_name'],
'account_number' => $data[$index]['account_number'],
'period' => $data[$index]['period'],
'file_name' => "{$data[$index]['client_name']}_{$data[$index]['account_number']}_{$data[$index]['period']}.csv"
];
}, array_keys($jobIds), $jobIds)
'success' => true,
'message' => 'Statement export job queued successfully',
'data' => [
'job_id' => $job->job_id ?? null,
'account_number' => $accountNumber,
'period' => $period,
'client_name' => $clientName,
'queue_name' => $queueName
]
]);
} catch (\Exception $e) {
Log::error("Failed to export statement", [
'error' => $e->getMessage(),
'account' => $accountNumber,
'period' => $period,
'queue_name' => $queueName
]);
return response()->json([
'success' => false,
'message' => 'Failed to queue statement export job',
'error' => $e->getMessage(),
'queue_name' => $queueName
]);
}
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',
]
];
}
function listPeriod(){
return [
date('Ymd', strtotime('-1 day'))
];
}
function getAccountBalance($accountNumber, $period)
{
$accountBalance = AccountBalance::where('account_number', $accountNumber)
->where('period', '<', $period)
->orderBy('period', 'desc')
->first();
return $accountBalance->actual_balance ?? 0;
}
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';
try {
\Log::info("Starting statement export for account: {$accountNumber}, period: {$period}, client: {$clientName}");
// Validate inputs
if (empty($accountNumber) || empty($period) || empty($clientName)) {
throw new \Exception('Required parameters missing');
}
// Dispatch the job
$job = ExportStatementPeriodJob::dispatch($accountNumber, $period, $balance, $clientName);
\Log::info("Statement export job dispatched successfully", [
'job_id' => $job->job_id ?? null,
'account' => $accountNumber,
'period' => $period,
'client' => $clientName
]);
return response()->json([
'success' => true,
'message' => 'Statement export job queued successfully',
'data' => [
'job_id' => $job->job_id ?? null,
'account_number' => $accountNumber,
'period' => $period,
'client_name' => $clientName
]
]);
} 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

@@ -0,0 +1,317 @@
<?php
namespace Modules\Webstatement\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Exceptions\HttpResponseException;
use Modules\Webstatement\Enums\ResponseCode;
use Modules\Webstatement\Models\AccountBalance;
class BalanceSummaryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
try {
// Ambil parameter dari header
$signature = $this->header('X-Signature');
$timestamp = $this->header('X-Timestamp');
$apiKey = $this->header('X-Api-Key');
// Validasi keberadaan header yang diperlukan
if (!$signature || !$timestamp || !$apiKey) {
Log::warning('HMAC validation failed - missing required headers', [
'signature' => $signature,
'timestamp' => $timestamp,
'apiKey' => $apiKey,
'ip' => $this->ip(),
'user_agent' => $this->userAgent()
]);
return false;
}
// Validasi API key dari config
$expectedApiKey = config('webstatement.api_key');
if ($apiKey !== $expectedApiKey) {
Log::warning('HMAC validation failed - invalid API key', [
'provided_api_key' => $apiKey,
'expected_api_key' => $expectedApiKey,
'ip' => $this->ip(),
'user_agent' => $this->userAgent()
]);
return false;
}
// Ambil secret key dari config
$secretKey = config('webstatement.secret_key');
// Ambil parameter untuk validasi HMAC
$httpMethod = $this->method();
$relativeUrl = $this->path();
$requestBody = $this->getContent();
// Validasi HMAC signature
$isValid = validateHmac512(
$httpMethod,
$relativeUrl,
$apiKey,
$requestBody,
$timestamp,
$secretKey,
$signature
);
if (!$isValid) {
Log::warning('HMAC validation failed - invalid signature', [
'http_method' => $httpMethod,
'relative_url' => $relativeUrl,
'api_key' => $apiKey,
'timestamp' => $timestamp,
'ip' => $this->ip(),
'user_agent' => $this->userAgent()
]);
}
return $isValid;
} catch (\Exception $e) {
Log::error('HMAC validation error', [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'ip' => $this->ip(),
'user_agent' => $this->userAgent()
]);
return false;
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'account_number' => [
'required',
'string',
'max:10',
'min:10',
'exists:account_balances,account_number',
'regex:/^[0-9]+$/' // Numeric only
],
'start_date' => [
'required',
'date_format:Y-m-d',
'before_or_equal:end_date',
'after_or_equal:1900-01-01',
'before_or_equal:today'
],
'end_date' => [
'required',
'date_format:Y-m-d',
'after_or_equal:start_date',
'after_or_equal:1900-01-01',
'before_or_equal:today'
],
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'account_number.exists' => 'Nomor rekening tidak ditemukan.',
'account_number.required' => 'Nomor rekening wajib diisi.',
'account_number.string' => 'Nomor rekening harus berupa teks.',
'account_number.max' => 'Nomor rekening maksimal :max karakter.',
'account_number.min' => 'Nomor rekening minimal :min karakter.',
'account_number.regex' => 'Nomor rekening hanya boleh mengandung angka.',
'start_date.required' => 'Tanggal awal wajib diisi.',
'start_date.date_format' => 'Format tanggal awal harus YYYY-MM-DD.',
'start_date.before_or_equal' => 'Tanggal awal harus sebelum atau sama dengan tanggal akhir.',
'end_date.required' => 'Tanggal akhir wajib diisi.',
'end_date.date_format' => 'Format tanggal akhir harus YYYY-MM-DD.',
'end_date.after_or_equal' => 'Tanggal akhir harus sesudah atau sama dengan tanggal awal.',
'end_date.before_or_equal' => 'Tanggal akhir harus sebelum atau sama dengan hari ini.',
];
}
/**
* Handle a failed validation attempt.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function failedValidation($validator)
{
$errors = $validator->errors();
if($errors->has('account_number') && $errors->first('account_number') === 'Nomor rekening tidak ditemukan.') {
throw new HttpResponseException(
response()->json(
ResponseCode::ACCOUNT_NOT_FOUND->toResponse(
null,
'Nomor rekening tidak ditemukan'
),
ResponseCode::ACCOUNT_NOT_FOUND->getHttpStatus()
)
);
}
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => $errors->all()],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
/**
* Handle failed authorization.
*
* @return void
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
protected function failedAuthorization()
{
$xApiKey = $this->header('X-Api-Key');
$xSignature = $this->header('X-Signature');
$xTimestamp = $this->header('X-Timestamp');
$expectedApiKey = config('webstatement.api_key');
if(!$xApiKey){
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Api-Key' => 'API Key wajib diisi']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
if(!$xSignature){
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Signature' => 'Signature wajib diisi']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
if(!$xTimestamp){
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Timestamp' => 'Timestamp wajib diisi']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
// Validasi format timestamp ISO 8601
if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/', $xTimestamp)) {
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Timestamp' => 'Format timestamp tidak valid. Gunakan format ISO 8601 (YYYY-MM-DDTHH:MM:SS.sssZ)']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
// Validasi timestamp tidak lebih dari 5 menit dari waktu sekarang
try {
$timestamp = new \DateTime($xTimestamp);
$now = new \DateTime();
$diff = $now->getTimestamp() - $timestamp->getTimestamp();
if (abs($diff) > 300) { // 5 menit = 300 detik
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Timestamp' => 'Timestamp expired. Maksimal selisih 5 menit dari waktu sekarang']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
} catch (\Exception $e) {
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_FIELD->toResponse(
['errors' => ['X-Timestamp' => 'Timestamp tidak dapat diproses']],
'Validasi gagal'
),
ResponseCode::INVALID_FIELD->getHttpStatus()
)
);
}
// Cek apakah ini karena invalid API key
if ($xApiKey !== $expectedApiKey) {
throw new HttpResponseException(
response()->json(
ResponseCode::INVALID_TOKEN->toResponse(
null,
'API Key tidak valid'
),
ResponseCode::INVALID_TOKEN->getHttpStatus()
)
);
}
// Untuk kasus HMAC signature tidak valid
throw new HttpResponseException(
response()->json(
ResponseCode::UNAUTHORIZED->toResponse(
null,
'Signature tidak valid'
),
ResponseCode::UNAUTHORIZED->getHttpStatus()
)
);
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
Log::info('Balance summary request received', [
'input' => $this->all(),
'ip' => $this->ip(),
'user_agent' => $this->userAgent()
]);
}
}

View File

@@ -62,7 +62,7 @@ class PrintStatementRequest extends FormRequest
// Hanya cek duplikasi jika account_number ada
if (!empty($this->input('account_number'))) {
$query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected')
//->where('authorization_status', '!=', 'rejected')
->where(function($query) {
$query->where('is_available', true)
->orWhere('is_generated', true);
@@ -84,7 +84,7 @@ class PrintStatementRequest extends FormRequest
}
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
//$fail('A statement request with this account number and period already exists.');
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Modules\Webstatement\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Str;
class BalanceSummaryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param Request $request
* @return array
*/
public function toArray($request): array
{
return [
'account_number' => $this['account_number'],
'period' => [
'start_date' => $this['period']['start_date'],
'end_date' => $this['period']['end_date'],
],
'opening_balance' => [
'date' => $this['opening_balance']['date'],
'balance' => $this['opening_balance']['balance'],
'formatted_balance' => number_format($this['opening_balance']['balance'], 2, ',', '.'),
],
'closing_balance' => [
'date' => $this['closing_balance']['date'],
'balance' => $this['closing_balance']['balance'],
'formatted_balance' => number_format($this['closing_balance']['balance'], 2, ',', '.'),
'base_balance' => [
'date' => $this['closing_balance']['base_balance']['date'],
'balance' => $this['closing_balance']['base_balance']['balance'],
'formatted_balance' => number_format($this['closing_balance']['base_balance']['balance'], 2, ',', '.'),
],
'transactions_on_end_date' => $this['closing_balance']['transactions_on_end_date'],
'formatted_transactions_on_end_date' => number_format($this['closing_balance']['transactions_on_end_date'], 2, ',', '.'),
]
];
}
}

View File

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

View File

@@ -38,6 +38,7 @@ class ExportStatementPeriodJob implements ShouldQueue
protected $account_number;
protected $period; // Format: YYYYMM (e.g., 202505)
protected $endPeriod; // Format: YYYYMM (e.g., 202505)
protected $saldo;
protected $disk;
protected $client;
@@ -57,11 +58,12 @@ class ExportStatementPeriodJob implements ShouldQueue
* @param string $client
* @param string $disk
*/
public function __construct(int $statementId, string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local', bool $toCsv = true)
public function __construct(int $statementId, string $account_number, string $period, string $endPeriod, string $saldo, string $client = '', string $disk = 'local', bool $toCsv = true)
{
$this->statementId = $statementId;
$this->account_number = $account_number;
$this->period = $period;
$this->endPeriod = $endPeriod;
$this->saldo = $saldo;
$this->disk = $disk;
$this->client = $client;
@@ -69,13 +71,13 @@ class ExportStatementPeriodJob implements ShouldQueue
$this->toCsv = $toCsv;
// Calculate start and end dates based on period
$this->calculatePeriodDates();
$this->formatPeriodForFolder();
}
/**
* Calculate start and end dates for the given period
*/
private function calculatePeriodDates(): void
private function formatPeriodForFolder(): void
{
$year = substr($this->period, 0, 4);
$month = substr($this->period, 4, 2);
@@ -90,6 +92,13 @@ class ExportStatementPeriodJob implements ShouldQueue
// End date is always the last day of the month
$this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay();
// If endPeriod is provided, use it instead of endDate
if($this->endPeriod){
$year = substr($this->endPeriod, 0, 4);
$month = substr($this->endPeriod, 4, 2);
$this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay();
}
}
/**
@@ -201,25 +210,31 @@ class ExportStatementPeriodJob implements ShouldQueue
$processedData = [];
foreach ($entries as $item) {
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$globalSequence++;
$actualDate = $this->formatActualDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'created_at' => now(),
'updated_at' => now(),
];
$amount = $item->amount_fcy;
if($item->currency=='IDR'){
$amount = $item->amount_lcy;
}
$runningBalance += (float) $amount;
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $amount,
'transaction_type' => $amount < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'recipt_no' => $item->ft?->recipt_no ?? '-',
'created_at' => now(),
'updated_at' => now(),
];
}
return $processedData;
@@ -472,37 +487,22 @@ class ExportStatementPeriodJob implements ShouldQueue
// Generate filename
$filename = "{$this->account_number}_{$this->period}.pdf";
// Tentukan path storage
$storagePath = "statements/{$this->period}/{$account->branch_code}";
// Tentukan path storage dengan format folder baru
$periodPath = formatPeriodForFolder($this->period);
$storagePath = "statements/{$periodPath}/{$account->branch_code}";
$tempPath = storage_path("app/temp/{$filename}");
$fullStoragePath = "{$storagePath}/{$filename}";
// Buat direktori temp jika belum ada
if (!is_dir(dirname($tempPath))) {
mkdir(dirname($tempPath), 0777, true);
// Tambahkan pengecekan function dan error handling
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($tempPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($tempPath), 'www-data');
}
}
// Pastikan direktori storage ada
Storage::makeDirectory($storagePath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$storagePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
// Tambahkan pengecekan function dan error handling
if (function_exists('chown') && posix_getuid() === 0) {
@chown($fullPath, 'www-data'); // Gunakan www-data instead of root
@chgrp($fullPath, 'www-data');
}
}
}
$period = $this->period;
$endPeriod = $this->endPeriod;
// Render HTML view
$html = view('webstatement::statements.stmt', compact(
@@ -512,6 +512,7 @@ class ExportStatementPeriodJob implements ShouldQueue
'headerTableBg',
'branch',
'period',
'endPeriod',
'saldoAwalBulan'
))->render();
@@ -627,19 +628,10 @@ class ExportStatementPeriodJob implements ShouldQueue
// Determine the base path based on client
$account = Account::where('account_number', $this->account_number)->first();
$storagePath = "statements/{$this->period}/{$account->branch_code}";
$periodPath = formatPeriodForFolder($this->period);
$storagePath = "statements/{$periodPath}/{$account->branch_code}";
Storage::disk($this->disk)->makeDirectory($storagePath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$storagePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($fullPath), 'www-data');
}
}
}
$filePath = "{$storagePath}/{$this->fileName}";
// Delete existing file if it exists
@@ -669,7 +661,6 @@ class ExportStatementPeriodJob implements ShouldQueue
// Write to file incrementally to reduce memory usage
Storage::disk($this->disk)->append($filePath, $csvContent);
$csvContent = ''; // Reset content after writing
});
Log::info("Statement exported to {$this->disk} disk: {$filePath}");

View File

@@ -184,18 +184,16 @@
->whereNotNull('currency')
->where('currency', '!=', '')
->whereIn('ctdesc', $cardTypes)
->whereNotIn('product_code',['6002','6004','6042','6031']) // Hapus 6021 dari sini
->whereNotIn('product_code',['6031','6021','6042']) // Hapus 6021 dari sini
->where('branch','!=','ID0019999')
// Filter khusus: Kecualikan product_code 6021 yang ctdesc nya gold
->where(function($subQuery) {
$subQuery->where('product_code', '!=', '6021')
->orWhere(function($nestedQuery) {
$nestedQuery->where('product_code', '6021')
->where('ctdesc', '!=', 'gold');
});
->where(function($query) {
$query->whereNot(function($q) {
$q->where('product_code', '6004')
->where('ctdesc', 'CLASSIC');
});
});
$cards = $query->get();
@@ -203,8 +201,8 @@
Log::info('Eligible ATM cards fetched successfully', [
'total_cards' => $cards->count(),
'periode' => $this->periode,
'excluded_product_codes' => ['6002','6004','6042','6031'],
'special_filter' => 'product_code 6021 dengan ctdesc gold dikecualikan'
'excluded_product_codes' => ['6021','6042','6031'],
'special_filter' => 'product_code 6004 dengan ctdesc classic dikecualikan'
]);
return $cards;
@@ -251,6 +249,8 @@
: array
{
$today = date('Ymd');
// Generate hash string unik 16 digit
$uniqueHash = substr(hash('sha256', $card->crdno . $today . microtime(true) . uniqid()), 0, 16);
return [
'',
@@ -272,7 +272,8 @@
'',
'',
'',
'ACAT'
'ACAT',
$uniqueHash
];
}

View File

@@ -0,0 +1,641 @@
<?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, SerializesModels};
use Illuminate\Support\Facades\{DB, Log, Storage};
use Modules\Webstatement\Models\{AccountBalance,
ClosingBalanceReportLog,
ProcessedClosingBalance,
StmtEntry,
StmtEntryDetail};
/**
* Job untuk generate laporan closing balance dengan optimasi performa
* Menggunakan database staging sebelum export CSV
*/
class GenerateClosingBalanceReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $accountNumber;
protected $period;
protected $reportLogId;
protected $groupName;
protected $chunkSize = 1000;
protected $disk = 'local';
/**
* Create a new job instance.
*/
public function __construct(string $accountNumber, string $period, int $reportLogId, string $groupName = 'DEFAULT')
{
$this->accountNumber = $accountNumber;
$this->period = $period;
$this->reportLogId = $reportLogId;
$this->groupName = $groupName ?? 'DEFAULT';
}
/**
* Execute the job dengan optimasi performa
*/
public function handle(): void
{
$reportLog = ClosingBalanceReportLog::find($this->reportLogId);
if (!$reportLog) {
Log::error('Closing balance report log not found', ['id' => $this->reportLogId]);
return;
}
try {
Log::info('Starting optimized closing balance report generation', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'group_name' => $this->groupName,
'report_log_id' => $this->reportLogId
]);
// Update status to processing
$reportLog->update([
'status' => 'processing',
'updated_at' => now()
]);
// Gunakan satu transaksi untuk seluruh proses
DB::transaction(function () use ($reportLog) {
// Step 1: Process and save to database (fast)
$this->processAndSaveClosingBalanceData();
// Step 2: Export from database to CSV (fast)
$filePath = $this->exportFromDatabaseToCsv();
// Get record count from database
$recordCount = $this->getProcessedRecordCount();
// Update report log with success
$reportLog->update([
'status' => 'completed',
'file_path' => $filePath,
'file_size' => Storage::disk($this->disk)->size($filePath),
'record_count' => $recordCount,
'updated_at' => now()
]);
Log::info('Optimized closing balance report generation completed successfully', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'file_path' => $filePath,
'record_count' => $recordCount
]);
});
} catch (Exception $e) {
Log::error('Error generating optimized closing balance report', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
$reportLog->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_at' => now()
]);
throw $e;
}
}
/**
* Process and save closing balance data to database dengan proteksi duplikasi
* Memproses dan menyimpan data closing balance dengan perlindungan terhadap duplikasi
*/
private function processAndSaveClosingBalanceData(): void
{
$criteria = [
'account_number' => $this->accountNumber,
'period' => $this->period,
'group_name' => $this->groupName
];
// HAPUS DB::beginTransaction() - sudah ada di handle()
// Sederhana: hapus data existing terlebih dahulu seperti ExportStatementJob
$this->deleteExistingProcessedData($criteria);
// Get opening balance
$runningBalance = $this->getOpeningBalance();
$sequenceNo = 0;
Log::info('Starting to process closing balance data', [
'opening_balance' => $runningBalance,
'criteria' => $criteria
]);
// Build query yang sederhana tanpa eliminasi duplicate rumit
$query = $this->buildTransactionQuery();
// Proses dan insert data dengan batch updateOrCreate untuk efisiensi
$query->chunk($this->chunkSize, function ($transactions) use (&$runningBalance, &$sequenceNo) {
$processedData = $this->prepareProcessedClosingBalanceData($transactions, $runningBalance, $sequenceNo);
if (!empty($processedData)) {
// Gunakan batch processing untuk updateOrCreate
$this->batchUpdateOrCreate($processedData);
}
});
// HAPUS DB::commit() - akan di-handle di handle()
$recordCount = $this->getProcessedRecordCount();
Log::info('Closing balance data processing completed successfully', [
'final_sequence' => $sequenceNo,
'final_balance' => $runningBalance,
'record_count' => $recordCount
]);
}
/**
* Get opening balance from account balance table
* Mengambil saldo awal dari tabel account balance
*/
private function getOpeningBalance()
: float
{
Log::info('Getting opening balance', [
'account_number' => $this->accountNumber,
'period' => $this->period
]);
// Get previous period based on current period
$previousPeriod = $this->period === '20250512'
? Carbon::createFromFormat('Ymd', $this->period)->subDays(2)->format('Ymd')
: Carbon::createFromFormat('Ymd', $this->period)->subDay()->format('Ymd');
$accountBalance = AccountBalance::where('account_number', $this->accountNumber)
->where('period', $previousPeriod)
->first();
if (!$accountBalance) {
Log::warning('Account balance not found, using 0 as opening balance', [
'account_number' => $this->accountNumber,
'period' => $this->period
]);
return 0.0;
}
$openingBalance = (float) $accountBalance->actual_balance;
Log::info('Opening balance retrieved', [
'account_number' => $this->accountNumber,
'opening_balance' => $openingBalance
]);
return $openingBalance;
}
/**
* Build transaction query dengan pendekatan sederhana tanpa eliminasi duplicate rumit
*/
private function buildTransactionQuery()
{
Log::info('Building transaction query', [
'group_name' => $this->groupName,
'account_number' => $this->accountNumber,
'period' => $this->period
]);
$modelClass = $this->getModelByGroup();
$query = $modelClass::select([
'id',
'trans_reference',
'booking_date',
'amount_lcy',
'date_time'
])
->with([
'ft' => function ($query) {
$query->select('ref_no', 'date_time', 'debit_acct_no', 'debit_value_date',
'credit_acct_no', 'bif_rcv_acct', 'bif_rcv_name', 'credit_value_date',
'at_unique_id', 'bif_ref_no', 'atm_order_id', 'recipt_no',
'api_iss_acct', 'api_benff_acct', 'authoriser', 'remarks',
'payment_details', 'ref_no', 'merchant_id', 'term_id');
},
'dc' => function ($query) {
$query->select('id', 'date_time');
}
])
->where('account_number', $this->accountNumber)
->where('booking_date', $this->period)
->orderBy('booking_date')
->orderBy('date_time');
return $query;
}
/**
* Get model class based on group name
* Mendapatkan class model berdasarkan group name
*/
private function getModelByGroup()
{
Log::info('Determining model by group', [
'group_name' => $this->groupName
]);
$model = $this->groupName === 'QRIS' ? StmtEntryDetail::class : StmtEntry::class;
Log::info('Model determined', [
'group_name' => $this->groupName,
'model_class' => $model
]);
return $model;
}
/**
* Prepare processed closing balance data tanpa validasi duplikasi
*/
private function prepareProcessedClosingBalanceData($transactions, &$runningBalance, &$sequenceNo)
: array
{
$processedData = [];
foreach ($transactions as $transaction) {
$sequenceNo++;
// Process transaction data
$processedTransactionData = $this->processTransactionData($transaction);
// Update running balance
$amount = (float) $transaction->amount_lcy;
$runningBalance += $amount;
// Format transaction date
$transactionDate = $this->formatDateTime($processedTransactionData['date_time']);
// Prepare data untuk database insert tanpa unique_hash
$processedData[] = [
'account_number' => $this->accountNumber,
'period' => $this->period,
'group_name' => $this->groupName,
'sequence_no' => $sequenceNo,
'trans_reference' => $processedTransactionData['trans_reference'],
'booking_date' => $processedTransactionData['booking_date'],
'transaction_date' => $transactionDate,
'amount_lcy' => $processedTransactionData['amount_lcy'],
'debit_acct_no' => $processedTransactionData['debit_acct_no'],
'debit_value_date' => $processedTransactionData['debit_value_date'],
'debit_amount' => $processedTransactionData['debit_amount'],
'credit_acct_no' => $processedTransactionData['credit_acct_no'],
'bif_rcv_acct' => $processedTransactionData['bif_rcv_acct'],
'bif_rcv_name' => $processedTransactionData['bif_rcv_name'],
'credit_value_date' => $processedTransactionData['credit_value_date'],
'credit_amount' => $processedTransactionData['credit_amount'],
'at_unique_id' => $processedTransactionData['at_unique_id'],
'bif_ref_no' => $processedTransactionData['bif_ref_no'],
'atm_order_id' => $processedTransactionData['atm_order_id'],
'recipt_no' => $processedTransactionData['recipt_no'],
'api_iss_acct' => $processedTransactionData['api_iss_acct'],
'api_benff_acct' => $processedTransactionData['api_benff_acct'],
'authoriser' => $processedTransactionData['authoriser'],
'remarks' => $processedTransactionData['remarks'],
'payment_details' => $processedTransactionData['payment_details'],
'ref_no' => $processedTransactionData['ref_no'],
'merchant_id' => $processedTransactionData['merchant_id'],
'term_id' => $processedTransactionData['term_id'],
'closing_balance' => $runningBalance,
'created_at' => now(),
'updated_at' => now(),
];
}
Log::info('Processed closing balance data prepared', [
'total_records' => count($processedData)
]);
return $processedData;
}
/**
* Process transaction data from ORM result
* Memproses data transaksi dari hasil ORM
*/
private function processTransactionData($transaction)
: array
{
Log::info('Processing transaction data', [
'trans_reference' => $transaction->trans_reference,
'has_ft_relation' => !is_null($transaction->ft),
'has_dc_relation' => !is_null($transaction->dc)
]);
// Hitung debit dan credit amount
$debitAmount = $transaction->amount_lcy < 0 ? abs($transaction->amount_lcy) : null;
$creditAmount = $transaction->amount_lcy > 0 ? $transaction->amount_lcy : null;
// Ambil date_time dari prioritas: ft -> dc -> stmt
$dateTime = $transaction->ft?->date_time ??
$transaction->dc?->date_time ??
$transaction->date_time;
$processedData = [
'trans_reference' => $transaction->trans_reference,
'booking_date' => $transaction->booking_date,
'amount_lcy' => $transaction->amount_lcy,
'debit_amount' => $debitAmount,
'credit_amount' => $creditAmount,
'date_time' => $dateTime,
// Data dari TempFundsTransfer melalui relasi
'debit_acct_no' => $transaction->ft?->debit_acct_no,
'debit_value_date' => $transaction->ft?->debit_value_date,
'credit_acct_no' => $transaction->ft?->credit_acct_no,
'bif_rcv_acct' => $transaction->ft?->bif_rcv_acct,
'bif_rcv_name' => $transaction->ft?->bif_rcv_name,
'credit_value_date' => $transaction->ft?->credit_value_date,
'at_unique_id' => $transaction->ft?->at_unique_id,
'bif_ref_no' => $transaction->ft?->bif_ref_no,
'atm_order_id' => $transaction->ft?->atm_order_id,
'recipt_no' => $transaction->ft?->recipt_no,
'api_iss_acct' => $transaction->ft?->api_iss_acct,
'api_benff_acct' => $transaction->ft?->api_benff_acct,
'authoriser' => $transaction->ft?->authoriser,
'remarks' => $transaction->ft?->remarks,
'payment_details' => $transaction->ft?->payment_details,
'ref_no' => $transaction->ft?->ref_no,
'merchant_id' => $transaction->ft?->merchant_id,
'term_id' => $transaction->ft?->term_id,
];
Log::info('Transaction data processed successfully', [
'trans_reference' => $transaction->trans_reference,
'final_date_time' => $dateTime,
'debit_amount' => $debitAmount,
'credit_amount' => $creditAmount
]);
return $processedData;
}
/**
* Format datetime string
* Memformat string datetime
*/
private function formatDateTime(?string $datetime)
: string
{
if (!$datetime) {
return '';
}
try {
return Carbon::createFromFormat('ymdHi', $datetime)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning('Error formatting datetime', [
'datetime' => $datetime,
'error' => $e->getMessage()
]);
return $datetime;
}
}
/**
* Export from database to CSV (very fast)
*/
private function exportFromDatabaseToCsv()
: string
{
Log::info('Starting CSV export from database for closing balance report', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'group_name' => $this->groupName
]);
// Create directory structure
$basePath = "closing_balance_reports";
$accountPath = "{$basePath}/{$this->accountNumber}";
Storage::disk($this->disk)->makeDirectory($basePath);
Storage::disk($this->disk)->makeDirectory($accountPath);
// Generate filename
$fileName = "closing_balance_{$this->accountNumber}_{$this->period}_{$this->groupName}.csv";
$filePath = "{$accountPath}/{$fileName}";
// Delete existing file if exists
if (Storage::disk($this->disk)->exists($filePath)) {
Storage::disk($this->disk)->delete($filePath);
}
// Create CSV header
$csvHeader = [
'NO',
'TRANS_REFERENCE',
'BOOKING_DATE',
'TRANSACTION_DATE',
'AMOUNT_LCY',
'DEBIT_ACCT_NO',
'DEBIT_VALUE_DATE',
'DEBIT_AMOUNT',
'CREDIT_ACCT_NO',
'BIF_RCV_ACCT',
'BIF_RCV_NAME',
'CREDIT_VALUE_DATE',
'CREDIT_AMOUNT',
'AT_UNIQUE_ID',
'BIF_REF_NO',
'ATM_ORDER_ID',
'RECIPT_NO',
'API_ISS_ACCT',
'API_BENFF_ACCT',
'AUTHORISER',
'REMARKS',
'PAYMENT_DETAILS',
'REF_NO',
'MERCHANT_ID',
'TERM_ID',
'CLOSING_BALANCE'
];
$csvContent = implode('|', $csvHeader) . "\n";
Storage::disk($this->disk)->put($filePath, $csvContent);
// Inisialisasi counter untuk sequence number
$sequenceCounter = 1;
$processedHashes = [];
ProcessedClosingBalance::where('account_number', $this->accountNumber)
->where('period', $this->period)
->where('group_name', $this->groupName)
->orderBy('sequence_no')
->chunk($this->chunkSize, function ($records) use ($filePath, &$sequenceCounter, &$processedHashes) {
$csvContent = [];
foreach ($records as $record) {
// Pengecekan unique_hash: skip jika sudah diproses
if (in_array($record->unique_hash, $processedHashes)) {
Log::debug('Skipping duplicate unique_hash in CSV export', [
'unique_hash' => $record->unique_hash,
'trans_reference' => $record->trans_reference
]);
continue;
}
// Tandai unique_hash sebagai sudah diproses
$processedHashes[] = $record->unique_hash;
$csvRow = [
$sequenceCounter++,
// Gunakan counter yang bertambah, bukan sequence_no dari database
$record->trans_reference ?? '',
$record->booking_date ?? '',
$record->transaction_date ?? '',
$record->amount_lcy ?? '',
$record->debit_acct_no ?? '',
$record->debit_value_date ?? '',
$record->debit_amount ?? '',
$record->credit_acct_no ?? '',
$record->bif_rcv_acct ?? '',
$record->bif_rcv_name ?? '',
$record->credit_value_date ?? '',
$record->credit_amount ?? '',
$record->at_unique_id ?? '',
$record->bif_ref_no ?? '',
$record->atm_order_id ?? '',
$record->recipt_no ?? '',
$record->api_iss_acct ?? '',
$record->api_benff_acct ?? '',
$record->authoriser ?? '',
$record->remarks ?? '',
$record->payment_details ?? '',
$record->ref_no ?? '',
$record->merchant_id ?? '',
$record->term_id ?? '',
$record->closing_balance ?? ''
];
$csvContent .= implode('|', $csvRow) . "\n";
}
if (!empty($csvContent)) {
Storage::disk($this->disk)->append($filePath, $csvContent);
Log::debug('CSV content appended', [
'records_processed' => substr_count($csvContent, "\n"),
'current_sequence' => $sequenceCounter - 1
]);
}
});
// Verify file creation
if (!Storage::disk($this->disk)->exists($filePath)) {
throw new Exception("Failed to create CSV file: {$filePath}");
}
Log::info('CSV export from database completed successfully', [
'file_path' => $filePath,
'file_size' => Storage::disk($this->disk)->size($filePath)
]);
return $filePath;
}
/**
* Get processed record count
*/
private function getProcessedRecordCount()
: int
{
return ProcessedClosingBalance::where('account_number', $this->accountNumber)
->where('period', $this->period)
->where('group_name', $this->groupName)
->count();
}
/**
* Delete existing processed data dengan pendekatan sederhana seperti ExportStatementJob
*/
private function deleteExistingProcessedData(array $criteria)
: void
{
Log::info('Deleting existing processed data', $criteria);
$deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->delete();
Log::info('Existing processed data deleted', [
'deleted_count' => $deletedCount,
'criteria' => $criteria
]);
}
/**
* Batch update or create untuk mengurangi jumlah query dan lock
* Menggunakan pendekatan yang lebih efisien untuk menghindari max_lock_per_transaction
*/
private function batchUpdateOrCreate(array $processedData): void
{
Log::info('Starting batch updateOrCreate', [
'batch_size' => count($processedData)
]);
// Kumpulkan semua trans_reference yang akan diproses
$transReferences = collect($processedData)->pluck('trans_reference')->toArray();
// Ambil data yang sudah ada dalam satu query
$existingRecords = ProcessedClosingBalance::where('account_number', $this->accountNumber)
->where('period', $this->period)
->where('group_name', $this->groupName)
->whereIn('trans_reference', $transReferences)
->get()
->keyBy(function ($item) {
return $item->trans_reference . '_' . $item->amount_lcy;
});
$toInsert = [];
$toUpdate = [];
foreach ($processedData as $data) {
$key = $data['trans_reference'] . '_' . $data['amount_lcy'];
if ($existingRecords->has($key)) {
// Record sudah ada, siapkan untuk update
$existingRecord = $existingRecords->get($key);
$toUpdate[] = [
'id' => $existingRecord->id,
'data' => $data
];
} else {
// Record baru, siapkan untuk insert
$toInsert[] = $data;
}
}
// Batch insert untuk record baru
if (!empty($toInsert)) {
DB::table('processed_closing_balances')->insert($toInsert);
Log::info('Batch insert completed', ['count' => count($toInsert)]);
}
// Batch update untuk record yang sudah ada
if (!empty($toUpdate)) {
foreach ($toUpdate as $updateItem) {
ProcessedClosingBalance::where('id', $updateItem['id'])
->update($updateItem['data']);
}
Log::info('Batch update completed', ['count' => count($toUpdate)]);
}
Log::info('Batch updateOrCreate completed successfully', [
'inserted' => count($toInsert),
'updated' => count($toUpdate)
]);
}
}

View File

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

View File

@@ -20,7 +20,7 @@ class ProcessAccountDataJob implements ShouldQueue
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const HEADER_MAP = [
'id' => 'transaction_id',

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const HEADER_MAP = [
'id' => 'id_category',
'date_time' => 'date_time',

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const FIELD_MAP = [
'id' => null, // Not mapped to model
'date_time' => null, // Not mapped to model
@@ -144,6 +144,12 @@
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
// Exclude the last field from CSV
if (count($row) > 0) {
array_pop($row);
Log::info("Excluded last field from row $rowCount. New column count: " . count($row));
}
$csvHeaders = array_keys(self::FIELD_MAP);
if (count($csvHeaders) !== count($row)) {

View File

@@ -8,6 +8,7 @@
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\Customer;
@@ -19,7 +20,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';
@@ -112,13 +113,28 @@
return;
}
$headers = (new Customer())->getFillable();
// Read header from CSV file
$csvHeaders = fgetcsv($handle, 0, self::CSV_DELIMITER);
if ($csvHeaders === false) {
Log::error("Unable to read headers from file: $filePath");
fclose($handle);
return;
}
// Map CSV headers to database fields
$headerMapping = $this->getHeaderMapping($csvHeaders);
Log::info("CSV Headers found", [
'csv_headers' => $csvHeaders,
'mapped_fields' => array_values($headerMapping)
]);
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
$this->processRow($row, $csvHeaders, $headerMapping, $rowCount, $filePath);
// Process in chunks to avoid memory issues
if (count($this->customerBatch) >= self::CHUNK_SIZE) {
@@ -137,16 +153,62 @@
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
private function processRow(array $row, array $headers, int $rowCount, string $filePath)
/**
* Map CSV headers to database field names
* Memetakan header CSV ke nama field database
*/
private function getHeaderMapping(array $csvHeaders): array
{
$mapping = [];
$fillableFields = (new Customer())->getFillable();
foreach ($csvHeaders as $index => $csvHeader) {
$csvHeader = trim($csvHeader);
// Direct mapping untuk field yang sama
if (in_array($csvHeader, $fillableFields)) {
$mapping[$index] = $csvHeader;
continue;
}
// Custom mapping untuk field yang berbeda nama
$customMapping = [
'co_code' => 'branch_code', // co_code di CSV menjadi branch_code di database
'name_1' => 'name'
];
if (isset($customMapping[$csvHeader])) {
$mapping[$index] = $customMapping[$csvHeader];
} else {
// Jika field ada di fillable, gunakan langsung
if (in_array($csvHeader, $fillableFields)) {
$mapping[$index] = $csvHeader;
}
// Jika tidak ada mapping, skip field ini
}
}
return $mapping;
}
private function processRow(array $row, array $csvHeaders, array $headerMapping, int $rowCount, string $filePath)
: void
{
if (count($headers) !== count($row)) {
if (count($csvHeaders) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($headers) . ", Got: " . count($row));
count($csvHeaders) . ", Got: " . count($row));
return;
}
$data = array_combine($headers, $row);
// Map CSV data to database fields
$data = [];
foreach ($row as $index => $value) {
if (isset($headerMapping[$index])) {
$fieldName = $headerMapping[$index];
$data[$fieldName] = trim($value);
}
}
$this->addToBatch($data, $rowCount, $filePath);
}
@@ -175,12 +237,20 @@
/**
* Save batched records to the database
* Menyimpan data customer dalam batch ke database dengan transaksi
*/
private function saveBatch()
: void
{
if (empty($this->customerBatch)) {
return;
}
$batchSize = count($this->customerBatch);
Log::info("Starting batch save", ['batch_size' => $batchSize]);
try {
if (!empty($this->customerBatch)) {
DB::transaction(function () use ($batchSize) {
// Bulk insert/update customers
Customer::upsert(
$this->customerBatch,
@@ -188,14 +258,26 @@
array_diff((new Customer())->getFillable(), ['customer_code']) // Update columns
);
// Reset customer batch after processing
$this->customerBatch = [];
}
Log::info("Batch save completed successfully", ['batch_size' => $batchSize]);
});
// Reset customer batch after successful processing
$this->customerBatch = [];
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->customerBatch);
Log::error("Error in saveBatch", [
'error' => $e->getMessage(),
'batch_size' => $batchSize,
'trace' => $e->getTraceAsString()
]);
$this->errorCount += $batchSize;
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->customerBatch = [];
// Re-throw exception untuk handling di level atas
throw $e;
}
}

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const CSV_HEADERS = [
'id',
@@ -185,6 +185,12 @@
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
// Exclude the last field from CSV
if (count($row) > 0) {
//array_pop($row);
Log::info("Excluded last field from row $rowCount. New column count: " . count($row));
}
if (count(self::CSV_HEADERS) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count(self::CSV_HEADERS) . ", Got: " . count($row));

View File

@@ -27,7 +27,7 @@
];
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.FT.TXN.TYPE.CONDITION.csv';
private const DISK_NAME = 'sftpStatement';
private const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -19,7 +19,7 @@
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 const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -20,7 +20,7 @@ class ProcessProvinceDataJob implements ShouldQueue
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.PROVINCE.csv';
private const DISK_NAME = 'sftpStatement';
private const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;
@@ -29,7 +29,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Membuat instance job baru untuk memproses data provinsi
*
*
* @param string $period Periode data yang akan diproses
*/
public function __construct(string $period = '')
@@ -41,17 +41,17 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Menjalankan job untuk memproses file ST.PROVINCE.csv
* Menggunakan transaction untuk memastikan konsistensi data
*
*
* @return void
* @throws Exception
*/
public function handle(): void
{
DB::beginTransaction();
try {
Log::info('ProcessProvinceDataJob: Memulai pemrosesan data provinsi');
$this->initializeJob();
if ($this->period === '') {
@@ -62,10 +62,10 @@ class ProcessProvinceDataJob implements ShouldQueue
$this->processPeriod();
$this->logJobCompletion();
DB::commit();
Log::info('ProcessProvinceDataJob: Transaction berhasil di-commit');
} catch (Exception $e) {
DB::rollback();
Log::error('ProcessProvinceDataJob: Error dalam pemrosesan, transaction di-rollback: ' . $e->getMessage());
@@ -76,7 +76,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Inisialisasi pengaturan job
* Mengatur timeout dan reset counter
*
*
* @return void
*/
private function initializeJob(): void
@@ -85,14 +85,14 @@ class ProcessProvinceDataJob implements ShouldQueue
$this->processedCount = 0;
$this->errorCount = 0;
$this->skippedCount = 0;
Log::info('ProcessProvinceDataJob: Job diinisialisasi dengan timeout ' . self::MAX_EXECUTION_TIME . ' detik');
}
/**
* Memproses file untuk periode tertentu
* Mengambil file dari SFTP dan memproses data
*
*
* @return void
*/
private function processPeriod(): void
@@ -101,7 +101,7 @@ class ProcessProvinceDataJob implements ShouldQueue
$filePath = "$this->period/" . self::FILENAME;
Log::info('ProcessProvinceDataJob: Memproses periode ' . $this->period);
if (!$this->validateFile($disk, $filePath)) {
return;
}
@@ -113,7 +113,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Validasi keberadaan file di storage
*
*
* @param mixed $disk Storage disk instance
* @param string $filePath Path file yang akan divalidasi
* @return bool
@@ -133,7 +133,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Membuat file temporary untuk pemrosesan
*
*
* @param mixed $disk Storage disk instance
* @param string $filePath Path file sumber
* @return string Path file temporary
@@ -142,7 +142,7 @@ class ProcessProvinceDataJob implements ShouldQueue
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
Log::info("ProcessProvinceDataJob: File temporary dibuat: $tempFilePath");
return $tempFilePath;
}
@@ -150,7 +150,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Memproses file CSV dan mengimpor data ke database
* Format CSV: id~date_time~province~province_name
*
*
* @param string $tempFilePath Path file temporary
* @param string $filePath Path file asli untuk logging
* @return void
@@ -164,20 +164,20 @@ class ProcessProvinceDataJob implements ShouldQueue
}
Log::info("ProcessProvinceDataJob: Memulai pemrosesan file: $filePath");
$rowCount = 0;
$isFirstRow = true;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
// Skip header row
if ($isFirstRow) {
$isFirstRow = false;
Log::info("ProcessProvinceDataJob: Melewati header row: " . implode(self::CSV_DELIMITER, $row));
continue;
}
$this->processRow($row, $rowCount, $filePath);
}
@@ -187,7 +187,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Memproses satu baris data CSV
*
*
* @param array $row Data baris CSV
* @param int $rowCount Nomor baris untuk logging
* @param string $filePath Path file untuk logging
@@ -207,16 +207,16 @@ class ProcessProvinceDataJob implements ShouldQueue
'code' => trim($row[2]), // province code
'name' => trim($row[3]) // province_name
];
Log::debug("ProcessProvinceDataJob: Memproses baris $rowCount dengan data: " . json_encode($data));
$this->saveRecord($data, $rowCount, $filePath);
}
/**
* Menyimpan record provinsi ke database
* Menggunakan updateOrCreate untuk menghindari duplikasi
*
*
* @param array $data Data provinsi yang akan disimpan
* @param int $rowCount Nomor baris untuk logging
* @param string $filePath Path file untuk logging
@@ -237,10 +237,10 @@ class ProcessProvinceDataJob implements ShouldQueue
['code' => $data['code']], // Kondisi pencarian
['name' => $data['name']] // Data yang akan diupdate/insert
);
$this->processedCount++;
Log::debug("ProcessProvinceDataJob: Berhasil menyimpan provinsi ID: {$province->id}, Code: {$data['code']}, Name: {$data['name']}");
} catch (Exception $e) {
$this->errorCount++;
Log::error("ProcessProvinceDataJob: Error menyimpan data provinsi pada baris $rowCount di $filePath: " . $e->getMessage());
@@ -250,7 +250,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Membersihkan file temporary
*
*
* @param string $tempFilePath Path file temporary yang akan dihapus
* @return void
*/
@@ -264,7 +264,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Logging hasil akhir pemrosesan job
*
*
* @return void
*/
private function logJobCompletion(): void
@@ -273,14 +273,14 @@ class ProcessProvinceDataJob implements ShouldQueue
"Total diproses: {$this->processedCount}, " .
"Total error: {$this->errorCount}, " .
"Total dilewati: {$this->skippedCount}";
Log::info($message);
// Log summary untuk monitoring
if ($this->errorCount > 0) {
Log::warning("ProcessProvinceDataJob: Terdapat {$this->errorCount} error dalam pemrosesan");
}
if ($this->skippedCount > 0) {
Log::info("ProcessProvinceDataJob: Terdapat {$this->skippedCount} baris yang dilewati");
}
@@ -288,7 +288,7 @@ class ProcessProvinceDataJob implements ShouldQueue
/**
* Handle job failure
*
*
* @param Exception $exception
* @return void
*/
@@ -297,4 +297,4 @@ class ProcessProvinceDataJob implements ShouldQueue
Log::error('ProcessProvinceDataJob: Job gagal dijalankan: ' . $exception->getMessage());
Log::error('ProcessProvinceDataJob: Stack trace: ' . $exception->getTraceAsString());
}
}
}

View File

@@ -19,7 +19,7 @@ class ProcessSectorDataJob implements ShouldQueue
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 const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';

View File

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

View File

@@ -19,7 +19,7 @@
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';
private const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -19,7 +19,7 @@
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 const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -19,7 +19,7 @@
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 DISK_NAME = 'staging';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private const HEADER_MAP = [
'id' => 'id_teller',

View File

@@ -19,7 +19,7 @@
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';
private const DISK_NAME = 'staging';
private string $period = '';
private int $processedCount = 0;

View File

@@ -231,6 +231,7 @@
break;
case 'all_branches':
$query->orderBy('branch_code', 'asc');
// Tidak ada filter tambahan, ambil semua
break;
@@ -238,6 +239,7 @@
throw new InvalidArgumentException("Invalid request type: {$this->requestType}");
}
$query->orderBy('account_number');
$accounts = $query->get();
// Filter accounts yang memiliki email

View File

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

View File

@@ -29,8 +29,33 @@ class Customer extends Model
'home_rw',
'ktp_rt',
'ktp_rw',
'local_ref'
'local_ref',
'ktp_kelurahan',
'ktp_kecamatan',
'town_country',
'ktp_provinsi',
'post_code',
'l_dom_street',
'l_dom_rt',
'l_dom_kelurahan',
'l_dom_rw',
'l_dom_kecamatan',
'l_dom_provinsi',
'l_dom_t_country',
'l_dom_post_code'
];
/**
* Get the attributes that should be cast.
* Mendefinisikan casting untuk field-field tertentu
*/
protected function casts(): array
{
return [
'date_of_birth' => 'date',
'birth_incorp_date' => 'date',
];
}
public function accounts(){
return $this->hasMany(Account::class, 'customer_code', 'customer_code');
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model;
class ProcessedClosingBalance extends Model
{
protected $fillable = [
'account_number',
'period',
'group_name',
'sequence_no',
'trans_reference',
'booking_date',
'transaction_date',
'amount_lcy',
'debit_acct_no',
'debit_value_date',
'debit_amount',
'credit_acct_no',
'bif_rcv_acct',
'bif_rcv_name',
'credit_value_date',
'credit_amount',
'at_unique_id',
'bif_ref_no',
'atm_order_id',
'recipt_no',
'api_iss_acct',
'api_benff_acct',
'authoriser',
'remarks',
'payment_details',
'ref_no',
'merchant_id',
'term_id',
'closing_balance',
'unique_hash',
];
protected $casts = [
'amount_lcy' => 'decimal:2',
'debit_amount' => 'decimal:2',
'credit_amount' => 'decimal:2',
'closing_balance' => 'decimal:2'
];
}

View File

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

View File

@@ -0,0 +1,31 @@
<?php
namespace Modules\Webstatement\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Webstatement\Services\AccountBalanceService;
class BalanceServiceProvider extends ServiceProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->singleton(AccountBalanceService::class, function ($app) {
return new AccountBalanceService();
});
}
/**
* Get the services provided by the provider.
*
* @return array<string>
*/
public function provides(): array
{
return [AccountBalanceService::class];
}
}

View File

@@ -6,20 +6,24 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Traits\PathNamespace;
use Illuminate\Console\Scheduling\Schedule;
use Modules\Webstatement\Console\UnlockPdf;
use Modules\Webstatement\Console\CombinePdf;
use Modules\Webstatement\Console\ConvertHtmlToPdf;
use Modules\Webstatement\Console\ExportDailyStatements;
use Modules\Webstatement\Console\ProcessDailyMigration;
use Modules\Webstatement\Console\ExportPeriodStatements;
use Modules\Webstatement\Console\UpdateAllAtmCardsCommand;
use Modules\Webstatement\Console\CheckEmailProgressCommand;
use Modules\Webstatement\Console\AutoSendStatementEmailCommand;
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
use Modules\Webstatement\Console\SendStatementEmailCommand;
use Modules\Webstatement\Console\{
UnlockPdf,
CombinePdf,
ConvertHtmlToPdf,
ExportDailyStatements,
ProcessDailyStaging,
ExportPeriodStatements,
UpdateAllAtmCardsCommand,
CheckEmailProgressCommand,
GenerateBiayakartuCommand,
SendStatementEmailCommand,
GenerateAtmTransactionReport,
GenerateBiayaKartuCsvCommand,
AutoSendStatementEmailCommand,
GenerateClosingBalanceReportCommand,
GenerateClosingBalanceReportBulkCommand,
};
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
use Modules\Webstatement\Console\GenerateAtmTransactionReport;
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
class WebstatementServiceProvider extends ServiceProvider
{
@@ -53,6 +57,7 @@ class WebstatementServiceProvider extends ServiceProvider
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
$this->app->register(BalanceServiceProvider::class);
$this->app->bind(UpdateAtmCardBranchCurrencyJob::class);
}
@@ -64,7 +69,7 @@ class WebstatementServiceProvider extends ServiceProvider
$this->commands([
GenerateBiayakartuCommand::class,
GenerateBiayaKartuCsvCommand::class,
ProcessDailyMigration::class,
ProcessDailyStaging::class,
ExportDailyStatements::class,
CombinePdf::class,
ConvertHtmlToPdf::class,
@@ -74,7 +79,9 @@ class WebstatementServiceProvider extends ServiceProvider
SendStatementEmailCommand::class,
CheckEmailProgressCommand::class,
UpdateAllAtmCardsCommand::class,
AutoSendStatementEmailCommand::class
AutoSendStatementEmailCommand::class,
GenerateClosingBalanceReportCommand::class,
GenerateClosingBalanceReportBulkCommand::class,
]);
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Modules\Webstatement\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
use Modules\Webstatement\Models\AccountBalance;
use Modules\Webstatement\Models\StmtEntry;
class AccountBalanceService
{
/**
* Get balance summary (opening and closing balance)
*
* @param string $accountNumber
* @param string $startDate
* @param string $endDate
* @return array
*/
public function getBalanceSummary(string $accountNumber, string $startDate, string $endDate): array
{
return DB::transaction(function () use ($accountNumber, $startDate, $endDate) {
Log::info('Calculating balance summary', [
'account_number' => $accountNumber,
'start_date' => $startDate,
'end_date' => $endDate
]);
// Convert dates to Carbon instances
$startDateCarbon = Carbon::parse($startDate);
$endDateCarbon = Carbon::parse($endDate);
// Get opening balance (balance from previous day)
$openingBalanceDate = $startDateCarbon->copy()->subDay();
$openingBalance = $this->getAccountBalance($accountNumber, $openingBalanceDate);
// Get closing balance date (previous day from end date)
$closingBalanceDate = $endDateCarbon->copy()->subDay();
$closingBalanceBase = $this->getAccountBalance($accountNumber, $closingBalanceDate);
// Get transactions on end date
$transactionsOnEndDate = $this->getTransactionsOnDate($accountNumber, $endDate);
// Calculate closing balance
$closingBalance = $closingBalanceBase + $transactionsOnEndDate;
$result = [
'account_number' => $accountNumber,
'period' => [
'start_date' => $startDate,
'end_date' => $endDate
],
'opening_balance' => [
'date' => $openingBalanceDate->format('Y-m-d'),
'balance' => $openingBalance,
'formatted_balance' => number_format($openingBalance, 2)
],
'closing_balance' => [
'date' => $endDate,
'balance' => $closingBalance,
'formatted_balance' => number_format($closingBalance, 2),
'base_balance' => [
'date' => $closingBalanceDate->format('Y-m-d'),
'balance' => $closingBalanceBase,
'formatted_balance' => number_format($closingBalanceBase, 2)
],
'transactions_on_end_date' => $transactionsOnEndDate,
'formatted_transactions_on_end_date' => number_format($transactionsOnEndDate, 2)
]
];
Log::info('Balance summary calculated successfully', $result);
return $result;
});
}
/**
* Get account balance for specific date
*
* @param string $accountNumber
* @param Carbon $date
* @return float
*/
private function getAccountBalance(string $accountNumber, Carbon $date): float
{
$balance = AccountBalance::where('account_number', $accountNumber)
->where('period', $date->format('Ymd'))
->value('actual_balance');
if ($balance === null) {
Log::warning('Account balance not found', [
'account_number' => $accountNumber,
'date' => $date->format('Y-m-d'),
'period' => $date->format('Ymd')
]);
return 0.00;
}
return (float) $balance;
}
/**
* Get transactions on specific date
*
* @param string $accountNumber
* @param string $date
* @return float
*/
private function getTransactionsOnDate(string $accountNumber, string $date): float
{
$total = StmtEntry::where('account_number', $accountNumber)
->whereDate('value_date', $date)
->sum(DB::raw('CAST(amount_lcy AS DECIMAL(15,2))'));
return (float) $total;
}
/**
* Validate if account exists
*
* @param string $accountNumber
* @return bool
*/
public function validateAccount(string $accountNumber): bool
{
return AccountBalance::where('account_number', $accountNumber)->exists();
}
}

View File

@@ -5,4 +5,17 @@ return [
// ZIP file password configuration
'zip_password' => env('WEBSTATEMENT_ZIP_PASSWORD', 'statement123'),
/*
|--------------------------------------------------------------------------
| API Configuration
|--------------------------------------------------------------------------
|
| These configuration values are used for API authentication using HMAC
| signature validation. These keys are used to validate incoming API
| requests and ensure secure communication.
|
*/
'api_key' => env('API_KEY'),
'secret_key' => env('SECRET_KEY'),
];

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('processed_closing_balances', function (Blueprint $table) {
$table->id();
$table->string('account_number', 20)->index();
$table->string('period', 8)->index();
$table->string('group_name', 20)->default('DEFAULT')->index();
$table->integer('sequence_no');
$table->string('trans_reference', 50)->nullable();
$table->string('booking_date', 8)->nullable();
$table->string('transaction_date', 20)->nullable();
$table->decimal('amount_lcy', 15, 2)->nullable();
$table->string('debit_acct_no', 20)->nullable();
$table->string('debit_value_date', 8)->nullable();
$table->decimal('debit_amount', 15, 2)->nullable();
$table->string('credit_acct_no', 20)->nullable();
$table->string('bif_rcv_acct', 20)->nullable();
$table->string('bif_rcv_name', 100)->nullable();
$table->string('credit_value_date', 8)->nullable();
$table->decimal('credit_amount', 15, 2)->nullable();
$table->string('at_unique_id', 50)->nullable();
$table->string('bif_ref_no', 50)->nullable();
$table->string('atm_order_id', 50)->nullable();
$table->string('recipt_no', 50)->nullable();
$table->string('api_iss_acct', 20)->nullable();
$table->string('api_benff_acct', 20)->nullable();
$table->string('authoriser', 50)->nullable();
$table->text('remarks')->nullable();
$table->text('payment_details')->nullable();
$table->string('ref_no', 50)->nullable();
$table->string('merchant_id', 50)->nullable();
$table->string('term_id', 50)->nullable();
$table->decimal('closing_balance', 15, 2)->nullable();
$table->timestamps();
// Composite index untuk performa query
$table->index(['account_number', 'period', 'group_name']);
$table->index(['account_number', 'period', 'sequence_no']);
});
}
public function down()
{
Schema::dropIfExists('processed_closing_balances');
}
};

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('processed_closing_balances', function (Blueprint $table) {
$table->string('unique_hash')->after('id')->unique();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('processed_closing_balances', function (Blueprint $table) {
$table->dropColumn('unique_hash');
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Menambahkan field-field yang belum ada pada tabel customers
*/
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
// Field yang belum ada berdasarkan CSV header
$table->string('ktp_kelurahan')->nullable()->after('local_ref')->comment('Kelurahan sesuai KTP');
$table->string('ktp_kecamatan')->nullable()->after('ktp_kelurahan')->comment('Kecamatan sesuai KTP');
$table->string('town_country')->nullable()->after('ktp_kecamatan')->comment('Kota/Negara');
$table->string('ktp_provinsi')->nullable()->after('town_country')->comment('Provinsi sesuai KTP');
$table->string('post_code')->nullable()->after('ktp_provinsi')->comment('Kode pos alternatif');
$table->string('l_dom_street')->nullable()->after('post_code')->comment('Alamat domisili - jalan');
$table->string('l_dom_rt')->nullable()->after('l_dom_street')->comment('Alamat domisili - RT');
$table->string('l_dom_kelurahan')->nullable()->after('l_dom_rt')->comment('Alamat domisili - kelurahan');
$table->string('l_dom_rw')->nullable()->after('l_dom_kelurahan')->comment('Alamat domisili - RW');
$table->string('l_dom_kecamatan')->nullable()->after('l_dom_rw')->comment('Alamat domisili - kecamatan');
$table->string('l_dom_provinsi')->nullable()->after('l_dom_kecamatan')->comment('Alamat domisili - provinsi');
$table->string('l_dom_t_country')->nullable()->after('l_dom_provinsi')->comment('Alamat domisili - kota/negara');
$table->string('l_dom_post_code')->nullable()->after('l_dom_t_country')->comment('Alamat domisili - kode pos');
});
}
/**
* Reverse the migrations.
* Menghapus field-field yang ditambahkan
*/
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn([
'ktp_kelurahan',
'ktp_kecamatan',
'town_country',
'ktp_provinsi',
'post_code',
'l_dom_street',
'l_dom_rt',
'l_dom_kelurahan',
'l_dom_rw',
'l_dom_kecamatan',
'l_dom_provinsi',
'l_dom_t_country',
'l_dom_post_code'
]);
});
}
};

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
@section('content')
<div class="grid grid-cols-8 gap-5">
<div class="col-span-2 card">
<div class="col-span-2 bg-gray-100 card">
<div class="card-header">
<h3 class="card-title">Request Print Stetement</h3>
</div>
@@ -71,10 +71,6 @@
{{ in_array('BY.MAIL.TO.KTP.ADDR', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
BY MAIL TO KTP ADDR
</option>
<option value="NO.PRINT"
{{ in_array('NO.PRINT', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
NO PRINT
</option>
<option value="PRINT"
{{ in_array('PRINT', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
PRINT
@@ -135,6 +131,7 @@
@enderror
</div>
@if (auth()->user()->branch->code === '0988')
<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"
@@ -144,6 +141,7 @@
<em class="text-sm alert text-danger">{{ $message }}</em>
@enderror
</div>
@endif
</div>
<div class="mt-5 text-end">

View File

@@ -130,6 +130,11 @@
padding: 5px;
text-align: left;
font-size: 10px;
word-wrap: break-word;
word-break: break-word;
white-space: normal;
overflow-wrap: break-word;
hyphens: auto;
}
table th {
@@ -278,13 +283,17 @@
$totalDebit = 0;
$totalKredit = 0;
$line = 1;
$linePerPage = 26;
$linePerPage = 23;
@endphp
@php
// Hitung tanggal periode berdasarkan $period
$periodDates = calculatePeriodDates($period);
// Jika endPeriod ada, gunakan endPeriod sebagai batas akhir, jika tidak, gunakan period
$endPeriodDate = $endPeriod ? calculatePeriodDates($endPeriod) : $periodDates;
$startDate = $periodDates['start'];
$endDate = $periodDates['end'];
$endDate = $endPeriodDate['end'] ?? $periodDates['end'];
// Log hasil perhitungan
\Log::info('Period dates calculated', [
@@ -361,13 +370,22 @@
<div class="column">
<p>{{ $branch->name }}</p>
<p style="text-transform: capitalize">Kepada</p>
<p>{{ $account->customer->name }}</p>
<p>{{ $account->customer->address }}</p>
<p>{{ $account->customer->district }}
{{ ($account->customer->ktp_rt ?: $account->customer->home_rt) ? 'RT ' . ($account->customer->ktp_rt ?: $account->customer->home_rt) : '' }}
{{ ($account->customer->ktp_rw ?: $account->customer->home_rw) ? 'RW ' . ($account->customer->ktp_rw ?: $account->customer->home_rw) : '' }}
</p>
<p>{{ trim($account->customer->city . ' ' . ($account->customer->province ? getProvinceCoreName($account->customer->province) . ' ' : '') . ($account->customer->postal_code ?? '')) }}
<p>{{ $customer->name }}</p>
@if ($account->stmt_sent_type == 'BY.MAIL.TO.DOM.ADDR')
<p>{{ $customer->l_dom_street ?? $customer->address }}</p>
<p>{{ $customer->district }}
{{ ($customer->ktp_rt ?: $customer->home_rt) ? 'RT ' . ($customer->ktp_rt ?: $customer->home_rt) : '' }}
{{ ($customer->ktp_rw ?: $customer->home_rw) ? 'RW ' . ($customer->ktp_rw ?: $customer->home_rw) : '' }}
</p>
<p>{{ trim($customer->city . ' ' . ($customer->province ? getProvinceCoreName($customer->province) . ' ' : '') . ($customer->postal_code ?? '')) }}
@else
<p>{{ $customer->address }}</p>
<p>{{ $customer->district }}
{{ ($customer->ktp_rt ?: $customer->home_rt) ? 'RT ' . ($customer->ktp_rt ?: $customer->home_rt) : '' }}
{{ ($customer->ktp_rw ?: $customer->home_rw) ? 'RW ' . ($customer->ktp_rw ?: $customer->home_rw) : '' }}
</p>
<p>{{ trim($customer->city . ' ' . ($customer->province ? getProvinceCoreName($customer->province) . ' ' : '') . ($customer->postal_code ?? '')) }}
@endif
</p>
</div>
<div style="text-transform: capitalize;" class="column">
@@ -403,7 +421,7 @@
<td class="text-right">&nbsp;</td>
<td class="text-right">&nbsp;</td>
<td class="text-right">
<strong>{{ number_format((float)$saldoAwalBulan->actual_balance, 2, ',', '.') }}</strong>
<strong>{{ number_format((float) $saldoAwalBulan->actual_balance, 2, ',', '.') }}</strong>
</td>
</tr>
@@ -438,10 +456,12 @@
<td class="text-center">{{ substr($row->actual_date, 0, 10) }}</td>
<td>{{ str_replace(['[', ']'], ' ', $narrativeLines[0] ?? '') }}</td>
<td>{{ $row->reference_number }}</td>
<td class="text-right">{{ $debit > 0 ? number_format((float)$debit, 2, ',', '.') : '' }}</td>
<td class="text-right">{{ $kredit > 0 ? number_format((float)$kredit, 2, ',', '.') : '' }}
<td class="text-right">
{{ $debit > 0 ? number_format((float) $debit, 2, ',', '.') : '' }}</td>
<td class="text-right">
{{ $kredit > 0 ? number_format((float) $kredit, 2, ',', '.') : '' }}
</td>
<td class="text-right">{{ number_format((float)$saldo, 2, ',', '.') }}</td>
<td class="text-right">{{ number_format((float) $saldo, 2, ',', '.') }}</td>
</tr>
@for ($i = 1; $i < count($narrativeLines); $i++)
<tr class="narrative-line">

View File

@@ -3,7 +3,13 @@
use Illuminate\Support\Facades\Route;
use Modules\Webstatement\Http\Controllers\CustomerController;
use Modules\Webstatement\Http\Controllers\EmailBlastController;
use Modules\Webstatement\Http\Controllers\Api\AccountBalanceController;
Route::post('/email-blast', [EmailBlastController::class, 'sendEmailBlast']);
Route::get('/email-blast-history', [EmailBlastController::class, 'getEmailBlastHistory']);
Route::get('/customers/search', [CustomerController::class, 'search']);
// Account Balance API Routes
Route::prefix('balance')->group(function () {
Route::post('/', [AccountBalanceController::class, 'getBalanceSummary']);
});

View File

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

View File

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