Compare commits

16 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
1f140af94a fix(migration): perbaiki error PostgreSQL enum untuk request_type
Perubahan yang dilakukan:
- Memperbaiki typo nama tabel dari print_stetement_logs menjadi print_statement_logs.
- Menggunakan raw SQL untuk menambahkan nilai enum baru ke tipe data request_type.
- Menambahkan constraint check sebagai alternatif validasi agar kompatibel dengan PostgreSQL.
- Menambahkan rollback transaction untuk menjaga integritas data saat migrasi gagal.
- Menambahkan logging untuk memantau proses migrasi enum.
- Memperbaiki syntax error pada perintah ALTER TYPE di PostgreSQL.
- Menambahkan kondisi IF NOT EXISTS untuk menghindari error duplikat nilai enum.
- Mengimplementasikan strategi rollback yang aman untuk migrasi enum PostgreSQL.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Log;
if(!function_exists('calculatePeriodDates')) {
/**
* Fungsi untuk menghitung tanggal periode berdasarkan periode yang diberikan
* Jika periode 202505, mulai dari tanggal 9 sampai akhir bulan
* Jika periode lain, mulai dari tanggal 1 sampai akhir bulan
*/
function calculatePeriodDates($period)
{
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
// Log untuk debugging
Log::info('Calculating period dates', [
'period' => $period,
'year' => $year,
'month' => $month,
]);
if ($period === '202505') {
// Untuk periode 202505, mulai dari tanggal 9
$startDate = \Carbon\Carbon::createFromDate($year, $month, 9,'Asia/Jakarta');
} else {
// Untuk periode lain, mulai dari tanggal 1
$startDate = \Carbon\Carbon::createFromDate($year, $month, 1,'Asia/Jakarta');
}
// Tanggal akhir selalu akhir bulan
$endDate = \Carbon\Carbon::createFromDate($year, $month, 1)->endOfMonth();
return [
'start' => $startDate,
'end' => $endDate,
];
}
}

View File

@@ -1,22 +1,22 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
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\Mail;
use Illuminate\Support\Facades\Storage;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Http\Requests\PrintStatementRequest;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use ZipArchive;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Auth, DB, Log, Mail, Storage};
use Illuminate\Validation\Rule;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Http\Requests\PrintStatementRequest;
use Modules\Webstatement\Jobs\{ExportStatementPeriodJob, GenerateMultiAccountPdfJob};
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\{Account, AccountBalance, PrintStatementLog, ProcessedStatement};
use Spatie\Browsershot\Browsershot;
use ZipArchive;
ini_set('memory_limit', '2G'); // Atau '1G' untuk data yang sangat besar
ini_set('max_execution_time', 300000);
class PrintStatementController extends Controller
{
@@ -45,33 +45,45 @@
// Add account verification before storing
$accountNumber = $request->input('account_number'); // Assuming this is the field name for account number
// First, check if the account exists and get branch information
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
$branch_code = $account->branch_code;
$userBranchId = session('branch_id'); // Assuming branch ID is stored in session
$multiBranch = session('MULTI_BRANCH');
$request_type = "single_account";
if($request->input('branch_code') && !empty($request->input('stmt_sent_type'))){
$request_type = 'multi_account'; // Default untuk request manual
}
if($request_type=='single_account'){
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
$branch_code = $account->branch_code;
$userBranchId = session('branch_id'); // Assuming branch ID is stored in session
$multiBranch = session('MULTI_BRANCH');
if (!$multiBranch) {
// Check if account branch matches user's branch
if ($account->branch_id !== $userBranchId) {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak sesuai dengan cabang Anda. Transaksi tidak dapat dilanjutkan.');
if (!$multiBranch) {
// Check if account branch matches user's branch
if ($account->branch_id !== $userBranchId) {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak sesuai dengan cabang Anda. Transaksi tidak dapat dilanjutkan.');
}
}
}
// Check if account belongs to restricted branch ID0019999
if ($account->branch_id === 'ID0019999') {
// Check if account belongs to restricted branch ID0019999
if ($account->branch_id === 'ID0019999') {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening terdaftar pada cabang khusus. Silakan hubungi bagian HC untuk informasi lebih lanjut.');
}
// If all checks pass, proceed with storing data
// Your existing store logic here
} else {
// Account not found
return redirect()->route('statements.index')
->with('error', 'Nomor rekening terdaftar pada cabang khusus. Silakan hubungi bagian HC untuk informasi lebih lanjut.');
->with('error', 'Nomor rekening tidak ditemukan dalam sistem.');
}
// If all checks pass, proceed with storing data
// Your existing store logic here
} else {
// Account not found
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak ditemukan dalam sistem.');
if($request->input('branch_code')=== 'ID0019999') {
return redirect()->route('statements.index')
->with('error', 'tidak dapat dilakukan print statement unruk cabang khusus. Silakan hubungi bagian HC untuk informasi lebih lanjut.');
}
}
DB::beginTransaction();
@@ -79,20 +91,26 @@
try {
$validated = $request->validated();
$validated['request_type'] = 'single_account'; // Default untuk request manual
if($validated['branch_code'] && !empty($validated['stmt_sent_type'])){
$validated['request_type'] = 'multi_account'; // Default untuk request manual
}
// Add user tracking data dan field baru untuk single account request
$validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent();
$validated['request_type'] = 'single_account'; // Default untuk request manual
$validated['status'] = 'pending'; // Status awal
$validated['authorization_status'] = 'approved'; // Status otorisasi awal
$validated['total_accounts'] = 1; // Untuk single account
$validated['processed_accounts'] = 0;
$validated['success_count'] = 0;
$validated['failed_count'] = 0;
$validated['branch_code'] = $branch_code; // Awal tidak tersedia
$validated['success_count'] = 0;
$validated['failed_count'] = 0;
$validated['stmt_sent_type'] = $request->input('stmt_sent_type') ? implode(",",$request->input('stmt_sent_type')) : '';
$validated['branch_code'] = $validated['branch_code'] ?? $branch_code; // Awal tidak tersedia
// Create the statement log
$statement = PrintStatementLog::create($validated);
@@ -106,11 +124,13 @@
// Process statement availability check
$this->checkStatementAvailability($statement);
$statement = PrintStatementLog::find($statement->id);
if($statement->email){
$this->sendEmail($statement->id);
if(!$statement->is_available){
$this->printStatementRekening($statement);
}
//if($statement->email){
// $this->sendEmail($statement->id);
//}
DB::commit();
return redirect()->route('statements.index')
@@ -268,6 +288,10 @@
return back()->with('error', 'Statement is not available for download.');
}
if($statement->is_generated){
return $this->generated($statement->id);
}
DB::beginTransaction();
try {
@@ -415,7 +439,10 @@
'file_path' => $filePath
]);
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
return response()->download(
$disk->path($filePath),
"{$statement->account_number}_{$statement->period_from}.pdf"
);
} else {
Log::warning('Statement file not found', [
'statement_id' => $statement->id,
@@ -474,8 +501,9 @@
// Retrieve data from the database
$query = PrintStatementLog::query();
$query->whereNotNull('user_id');
if (!auth()->user()->hasRole('administrator')) {
if (!Auth::user()->role === 'administrator') {
$query->where(function($q) {
$q->where('user_id', Auth::id())
->orWhere('branch_code', Auth::user()->branch->code);
@@ -570,12 +598,14 @@
'period_to' => $item->is_period_range ? $item->period_to : null,
'authorization_status' => $item->authorization_status,
'is_available' => $item->is_available,
'is_generated' => $item->is_generated,
'is_downloaded' => $item->is_downloaded,
'created_at' => dateFormat($item->created_at, 1, 1),
'created_by' => $item->user->name ?? 'N/A',
'authorized_by' => $item->authorizer ? $item->authorizer->name : null,
'authorized_at' => $item->authorized_at ? $item->authorized_at->format('Y-m-d H:i:s') : null,
'remarks' => $item->remarks,
'request_type' => $item->request_type ?? 'N/A',
];
});
@@ -776,4 +806,754 @@
return "statement_{$accountNumber}_{$statement->period_from}.pdf";
}
/**
* Generate statement view atau PDF berdasarkan parameter
*
* @param string $norek Nomor rekening
* @param string $period Periode dalam format YYYYMM
* @param string|null $format Format output: 'html' atau 'pdf'
* @return \Illuminate\View\View|\Illuminate\Http\Response
*/
public function generated($id){
try {
$statement = PrintStatementLog::find($id);
DB::beginTransaction();
$norek = $statement->account_number;
$period = $statement->period_from;
$format='pdf';
// Generate nama file PDF
$filename = $this->generatePdfFileName($norek, $period);
// Tentukan path storage
$storagePath = "statements/{$period}/{$norek}";
$fullStoragePath = "{$storagePath}/{$filename}";
// Pastikan direktori storage ada
Storage::disk('local')->makeDirectory($storagePath);
// Path temporary untuk Browsershot
$tempPath = storage_path("app/{$fullStoragePath}");
if(file_exists($tempPath)){
return response()->download($tempPath, $filename, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"'
])->deleteFileAfterSend(false); // Keep file in storage
}
$stmtEntries = ProcessedStatement::where(['account_number' => $norek, 'period' => $period])->orderBy('sequence_no')->get();
$account = Account::with('customer')->where('account_number', $norek)->first();
if (!$account) {
throw new Exception("Account not found: {$norek}");
}
$customer = $account->customer;
$branch = Branch::where('code', $account->branch_code)->first();
// Cek apakah file gambar ada
$headerImagePath = public_path('assets/media/images/bg-header-table.png');
$headerTableBg = file_exists($headerImagePath)
? base64_encode(file_get_contents($headerImagePath))
: null;
// Logika untuk menentukan period saldo berdasarkan aturan baru
$saldoPeriod = $this->calculateSaldoPeriod($period);
Log::info('Calculated saldo period', [
'original_period' => $period,
'saldo_period' => $saldoPeriod
]);
$saldoAwalBulan = AccountBalance::where(['account_number' => $norek, 'period' => $saldoPeriod])->first();
if (!$saldoAwalBulan) {
Log::warning('Saldo awal bulan not found', [
'account_number' => $norek,
'saldo_period' => $saldoPeriod
]);
$saldoAwalBulan = (object) ['actual_balance' => 0];
}
DB::commit();
Log::info('Statement data prepared successfully', [
'account_number' => $norek,
'period' => $period,
'saldo_period' => $saldoPeriod,
'saldo_awal' => $saldoAwalBulan->actual_balance ?? 0,
'entries_count' => $stmtEntries->count()
]);
$periodDates = calculatePeriodDates($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);
}
// Default return HTML view
return view('webstatement::statements.stmt', compact('stmtEntries', 'account', 'customer', 'headerTableBg', 'branch', 'period', 'saldoAwalBulan'));
} catch (Exception $e) {
DB::rollBack();
Log::error('Failed to generate statement', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period,
'format' => $format,
'trace' => $e->getTraceAsString()
]);
if ($format === 'pdf') {
return response()->json([
'success' => false,
'message' => 'Failed to generate PDF statement',
'error' => $e->getMessage()
], 500);
}
throw $e;
}
}
/**
* Generate PDF dari statement HTML dan simpan ke storage
*
* @param string $norek Nomor rekening
* @param string $period Periode
* @param \Illuminate\Database\Eloquent\Collection $stmtEntries Data transaksi
* @param object $account Data akun
* @param object $customer Data customer
* @param string|null $headerTableBg Base64 encoded header image
* @param object $branch Data cabang
* @param object $saldoAwalBulan Data saldo awal
* @return \Illuminate\Http\Response
*/
protected function generateStatementPdf($norek, $period, $stmtEntries, $account, $customer, $headerTableBg, $branch, $saldoAwalBulan, $statementId, $tempPath, $filename)
{
try {
DB::beginTransaction();
Log::info('Starting PDF generation with storage', [
'account_number' => $norek,
'period' => $period,
'user_id' => Auth::id()
]);
// Render HTML view
$html = view('webstatement::statements.stmt', compact(
'stmtEntries',
'account',
'customer',
'headerTableBg',
'branch',
'period',
'saldoAwalBulan'
))->render();
// Tentukan path storage
$storagePath = "statements/{$period}/{$norek}";
$fullStoragePath = "{$storagePath}/{$filename}";
// Generate PDF menggunakan Browsershot dan simpan langsung ke storage
Browsershot::html($html)
->showBackground()
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
->format('A4')
->margins(0, 0, 0, 0)
->waitUntilNetworkIdle()
->timeout(6000)
->save($tempPath);
// Verifikasi file berhasil dibuat
if (!file_exists($tempPath)) {
throw new Exception('PDF file was not created successfully');
} else {
$printLog = PrintStatementLog::find($statementId);
if($printLog){
$printLog->update(['is_available' => true]);
}
}
$fileSize = filesize($tempPath);
Log::info('PDF generated and saved to storage', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $fullStoragePath,
'file_size' => $fileSize,
'temp_path' => $tempPath
]);
DB::commit();
// Return download response
return response()->download($tempPath, $filename, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"'
])->deleteFileAfterSend(false); // Keep file in storage
} catch (Exception $e) {
DB::rollBack();
Log::error('Failed to generate PDF with storage', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period,
'trace' => $e->getTraceAsString()
]);
throw new Exception('Failed to generate PDF: ' . $e->getMessage());
}
}
/**
* Generate nama file PDF berdasarkan nomor rekening dan periode
*
* @param string $norek Nomor rekening
* @param string $period Periode
* @return string
*/
protected function generatePdfFileName($norek, $period)
{
try {
$filename = "statement_{$norek}_{$period}.pdf";
Log::info('Generated PDF filename', [
'account_number' => $norek,
'period' => $period,
'filename' => $filename
]);
return $filename;
} catch (Exception $e) {
Log::error('Error generating PDF filename', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period
]);
// Fallback filename
return "statement_{$norek}_{$period}.pdf";
}
}
/**
* Simpan PDF ke storage dengan validasi dan logging
*
* @param string $tempPath Path temporary file
* @param string $storagePath Path di storage
* @param string $norek Nomor rekening
* @param string $period Periode
* @return string|null Path file yang disimpan
*/
protected function savePdfToStorage($tempPath, $storagePath, $norek, $period)
{
try {
// Validasi file temporary ada
if (!file_exists($tempPath)) {
throw new Exception('Temporary PDF file not found');
}
// Validasi ukuran file
$fileSize = filesize($tempPath);
if ($fileSize === 0) {
throw new Exception('PDF file is empty');
}
// Baca konten file
$pdfContent = file_get_contents($tempPath);
if ($pdfContent === false) {
throw new Exception('Failed to read PDF content');
}
// Simpan ke storage
$saved = Storage::disk('local')->put($storagePath, $pdfContent);
if (!$saved) {
throw new Exception('Failed to save PDF to storage');
}
// Verifikasi file tersimpan
if (!Storage::disk('local')->exists($storagePath)) {
throw new Exception('PDF file not found in storage after save');
}
$savedSize = Storage::disk('local')->size($storagePath);
Log::info('PDF successfully saved to storage', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath,
'original_size' => $fileSize,
'saved_size' => $savedSize,
'temp_path' => $tempPath
]);
return $storagePath;
} catch (Exception $e) {
Log::error('Failed to save PDF to storage', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath,
'temp_path' => $tempPath
]);
return null;
}
}
/**
* Ambil PDF dari storage untuk download
*
* @param string $norek Nomor rekening
* @param string $period Periode
* @param string $filename Nama file (optional)
* @return \Illuminate\Http\Response
*/
public function downloadFromStorage($norek, $period, $filename = null)
{
try {
// Generate filename jika tidak disediakan
if (!$filename) {
$filename = $this->generatePdfFileName($norek, $period);
}
$storagePath = "statements/{$period}/{$norek}/{$filename}";
// Cek apakah file ada di storage
if (!Storage::disk('local')->exists($storagePath)) {
Log::warning('PDF not found in storage', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath
]);
return response()->json([
'success' => false,
'message' => 'PDF file not found in storage'
], 404);
}
$fullPath = Storage::disk('local')->path($storagePath);
Log::info('PDF downloaded from storage', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath
]);
return response()->download($fullPath, $filename, [
'Content-Type' => 'application/pdf'
]);
} catch (Exception $e) {
Log::error('Failed to download PDF from storage', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period,
'filename' => $filename
]);
return response()->json([
'success' => false,
'message' => 'Failed to download PDF',
'error' => $e->getMessage()
], 500);
}
}
/**
* Hapus PDF dari storage
*
* @param string $norek Nomor rekening
* @param string $period Periode
* @param string $filename Nama file (optional)
* @return bool
*/
public function deleteFromStorage($norek, $period, $filename = null)
{
try {
if (!$filename) {
$filename = $this->generatePdfFileName($norek, $period);
}
$storagePath = "statements/{$period}/{$norek}/{$filename}";
if (Storage::disk('local')->exists($storagePath)) {
$deleted = Storage::disk('local')->delete($storagePath);
Log::info('PDF deleted from storage', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath,
'success' => $deleted
]);
return $deleted;
}
Log::warning('PDF not found for deletion', [
'account_number' => $norek,
'period' => $period,
'storage_path' => $storagePath
]);
return false;
} catch (Exception $e) {
Log::error('Failed to delete PDF from storage', [
'error' => $e->getMessage(),
'account_number' => $norek,
'period' => $period,
'filename' => $filename
]);
return false;
}
}
/**
* Menghitung period untuk pengambilan saldo berdasarkan aturan bisnis
* - Jika period = 202505, gunakan 20250510
* - Jika period > 202505, ambil tanggal akhir bulan sebelumnya
*
* @param string $period Format YYYYMM
* @return string Format YYYYMMDD
*/
private function calculateSaldoPeriod($period)
{
try {
// Jika period adalah 202505, gunakan tanggal 10 Mei 2025
if ($period === '202505') {
return '20250510';
}
// Jika period lebih dari 202505, ambil tanggal akhir bulan sebelumnya
if ($period > '202505') {
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
// Buat tanggal pertama bulan ini
$firstDayOfMonth = Carbon::createFromDate($year, $month, 1);
// Ambil tanggal terakhir bulan sebelumnya
$lastDayPrevMonth = $firstDayOfMonth->subDay();
return $lastDayPrevMonth->format('Ymd');
}
// Untuk period sebelum 202505, gunakan logika default (tanggal 10)
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
return $year . $month . '10';
} catch (Exception $e) {
Log::error('Error calculating saldo period', [
'period' => $period,
'error' => $e->getMessage()
]);
// Fallback ke format default
return $period . '10';
}
}
/**
* Process statement untuk multi account berdasarkan stmt_sent_type
*
* @param PrintStatementLog $statement
* @return \Illuminate\Http\JsonResponse
*/
function printStatementRekening($statement) {
try {
// DB::beginTransaction();
Log::info('Starting statement processing', [
'statement_id' => $statement->id,
'request_type' => $statement->request_type,
'stmt_sent_type' => $statement->stmt_sent_type,
'branch_code' => $statement->branch_code
]);
if ($statement->request_type === 'multi_account') {
return $this->processMultiAccountStatement($statement);
} else {
return $this->processSingleAccountStatement($statement);
}
} catch (\Exception $e) {
//DB::rollBack();
Log::error('Failed to process statement', [
'error' => $e->getMessage(),
'statement_id' => $statement->id,
'trace' => $e->getTraceAsString()
]);
return response()->json([
'success' => false,
'message' => 'Failed to process statement',
'error' => $e->getMessage()
], 500);
}
}
/**
* Process multi account statement berdasarkan stmt_sent_type
*
* @param PrintStatementLog $statement
* @return \Illuminate\Http\JsonResponse
*/
protected function processMultiAccountStatement($statement)
{
try {
$period = $statement->period_from ?? date('Ym');
// Validasi stmt_sent_type
if (empty($statement->stmt_sent_type)) {
throw new \Exception('stmt_sent_type is required for multi account processing');
}
// Decode stmt_sent_type jika dalam format JSON array
$stmtSentTypes = explode(',', $statement->stmt_sent_type);
Log::info('Processing multi account statement', [
'statement_id' => $statement->id,
'branch_code' => $statement->branch_code,
'stmt_sent_types' => $stmtSentTypes,
'period' => $period
]);
$clientName = $statement->branch_code.'_'.$period.'_';//.implode('_'.$stmtSentTypes);
// Ambil accounts berdasarkan branch_code dan stmt_sent_type
$accounts = Account::where('branch_code', $statement->branch_code)
->whereIn('stmt_sent_type', $stmtSentTypes)
->with('customer')
->limit(5)
->get();
if ($accounts->isEmpty()) {
throw new \Exception('No accounts found for the specified criteria');
}
Log::info('Found accounts for processing', [
'total_accounts' => $accounts->count(),
'branch_code' => $statement->branch_code,
'stmt_sent_types' => $stmtSentTypes
]);
// Update statement log dengan informasi accounts
$accountNumbers = $accounts->pluck('account_number')->toArray();
$statement->update([
'target_accounts' => implode(",",$accountNumbers),
'total_accounts' => $accounts->count(),
'status' => 'processing',
'started_at' => now()
]);
// Dispatch job untuk generate PDF multi account
$job = GenerateMultiAccountPdfJob::dispatch(
$statement,
$accounts,
$period,
$clientName
);
DB::commit();
Log::info('Multi account PDF generation job dispatched', [
'job_id' => $job->job_id ?? null,
'statement_id' => $statement->id,
'total_accounts' => $accounts->count(),
'period' => $period
]);
return response()->json([
'success' => true,
'message' => 'Multi account statement processing queued successfully',
'data' => [
'job_id' => $job->job_id ?? null,
'statement_id' => $statement->id,
'total_accounts' => $accounts->count(),
'account_numbers' => $accountNumbers,
'period' => $period,
'client_name' => $clientName
]
]);
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to process multi account statement', [
'error' => $e->getMessage(),
'statement_id' => $statement->id,
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Process single account statement (existing logic)
*
* @param PrintStatementLog $statement
* @return \Illuminate\Http\JsonResponse
*/
protected function processSingleAccountStatement($statement)
{
$accountNumber = $statement->account_number;
$period = $statement->period_from ?? 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($statement->id, $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()
]);
}
}
/**
* Download ZIP file untuk multi account statement
*
* @param int $statementId
* @return \Illuminate\Http\Response
*/
public function downloadMultiAccountZip($statementId)
{
try {
$statement = PrintStatementLog::findOrFail($statementId);
if ($statement->request_type !== 'multi_account') {
return response()->json([
'success' => false,
'message' => 'This statement is not a multi account request'
], 400);
}
if (!$statement->is_available) {
return response()->json([
'success' => false,
'message' => 'Statement files are not available for download'
], 404);
}
// Find ZIP file
$zipFiles = Storage::disk('local')->files("statements/{$statement->period_from}/multi_account/{$statementId}");
$zipFile = null;
foreach ($zipFiles as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'zip') {
$zipFile = $file;
break;
}
}
if (!$zipFile || !Storage::disk('local')->exists($zipFile)) {
return response()->json([
'success' => false,
'message' => 'ZIP file not found'
], 404);
}
$zipPath = Storage::disk('local')->path($zipFile);
$filename = basename($zipFile);
// Update download status
$statement->update([
'is_downloaded' => true,
'downloaded_at' => now()
]);
Log::info('Multi account ZIP downloaded', [
'statement_id' => $statementId,
'zip_file' => $zipFile,
'user_id' => Auth::id()
]);
return response()->download($zipPath, $filename, [
'Content-Type' => 'application/zip'
]);
} catch (Exception $e) {
Log::error('Failed to download multi account ZIP', [
'statement_id' => $statementId,
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Failed to download ZIP file',
'error' => $e->getMessage()
], 500);
}
}
}

View File

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

View File

@@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Webstatement\Models\ProcessedStatement;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempFundsTransfer;
@@ -31,6 +32,8 @@ class ExportStatementPeriodJob implements ShouldQueue
protected $chunkSize = 1000;
protected $startDate;
protected $endDate;
protected $toCsv;
protected $statementId;
/**
* Create a new job instance.
@@ -41,14 +44,16 @@ class ExportStatementPeriodJob implements ShouldQueue
* @param string $client
* @param string $disk
*/
public function __construct(string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local')
public function __construct(int $statementId, string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local', bool $toCsv = true)
{
$this->statementId = $statementId;
$this->account_number = $account_number;
$this->period = $period;
$this->saldo = $saldo;
$this->disk = $disk;
$this->client = $client;
$this->fileName = "{$account_number}_{$period}.csv";
$this->toCsv = $toCsv;
// Calculate start and end dates based on period
$this->calculatePeriodDates();
@@ -84,8 +89,9 @@ class ExportStatementPeriodJob implements ShouldQueue
Log::info("Date range: {$this->startDate->format('Y-m-d')} to {$this->endDate->format('Y-m-d')}");
$this->processStatementData();
$this->exportToCsv();
if($this->toCsv){
$this->exportToCsv();
}
Log::info("Export statement period job completed successfully for account: {$this->account_number}, period: {$this->period}");
} catch (Exception $e) {
Log::error("Error in ExportStatementPeriodJob: " . $e->getMessage());
@@ -104,20 +110,28 @@ class ExportStatementPeriodJob implements ShouldQueue
$existingDataCount = $this->getExistingProcessedCount($accountQuery);
// Only process if data is not fully processed
if ($existingDataCount !== $totalCount) {
//if ($existingDataCount !== $totalCount) {
$this->deleteExistingProcessedData($accountQuery);
$this->processAndSaveStatementEntries($totalCount);
}
//}
}
private function getTotalEntryCount(): int
{
return StmtEntry::where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
])
->count();
$query = StmtEntry::where('account_number', $this->account_number)
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
]);
Log::info("Getting total entry count with query: " . $query->toSql(), [
'bindings' => $query->getBindings(),
'account' => $this->account_number,
'start_date' => $this->startDate->format('Ymd'),
'end_date' => $this->endDate->format('Ymd')
]);
return $query->count();
}
private function getExistingProcessedCount(array $criteria): int
@@ -141,11 +155,11 @@ class ExportStatementPeriodJob implements ShouldQueue
Log::info("Processing {$totalCount} statement entries for account: {$this->account_number}");
StmtEntry::with(['ft', 'transaction'])
$entry = StmtEntry::with(['ft', 'transaction'])
->where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
])
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
@@ -156,6 +170,13 @@ class ExportStatementPeriodJob implements ShouldQueue
DB::table('processed_statements')->insert($processedData);
}
});
if($entry){
$printLog = PrintStatementLog::find($this->statementId);
if($printLog){
$printLog->update(['is_generated' => true]);
}
}
}
private function prepareProcessedData($entries, &$runningBalance, &$globalSequence): array
@@ -166,14 +187,13 @@ class ExportStatementPeriodJob implements ShouldQueue
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $transactionDate,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',

View File

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

View File

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

View File

@@ -15,6 +15,10 @@
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email;
if ($this->statement->is_period_range) {
$subject .= " - {$this->statement->period_from} to {$this->statement->period_to}";
} else {
$subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y');
class StatementEmail extends Mailable
{
use Queueable, SerializesModels;

View File

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

View File

@@ -44,11 +44,14 @@ class PrintStatementLog extends Model
'remarks',
'email',
'email_sent_at',
'stmt_sent_type',
'is_generated',
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_generated' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',

View File

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

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
$table->string('stmt_sent_type')->after('status')->nullable();
$table->boolean('is_generated')->after('is_available')->nullable()->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
$table->dropColumn('stmt_sent_type');
$table->dropColumn('is_generated');
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ Route::middleware(['auth'])->group(function () {
});
Route::resource('statements', PrintStatementController::class);
// ATM Transaction Report Routes
Route::group(['prefix' => 'atm-reports', 'as' => 'atm-reports.', 'middleware' => ['auth']], function () {
@@ -112,13 +112,8 @@ Route::middleware(['auth'])->group(function () {
Route::resource('email-statement-logs', EmailStatementLogController::class)->only(['index', 'show']);
});
Route::get('migrasi', [MigrasiController::class, 'index'])->name('migrasi.index');
Route::get('biaya-kartu', [SyncLogsController::class, 'index'])->name('biaya-kartu.index');
Route::get('/stmt-entries/{accountNumber}', [MigrasiController::class, 'getStmtEntryByAccount']);
Route::get('/stmt-export-csv', [WebstatementController::class, 'index'])->name('webstatement.index');
Route::prefix('debug')->group(function () {
Route::get('/test-statement',[WebstatementController::class,'printStatementRekening'])->name('webstatement.test');
Route::post('/statement', [DebugStatementController::class, 'debugStatement'])->name('debug.statement');