Compare commits
34 Commits
new
...
36abab1280
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36abab1280 | ||
|
|
7818d1677b | ||
|
|
92afe58e66 | ||
|
|
c264d63fa6 | ||
|
|
6bd8b77d87 | ||
|
|
efabba4c39 | ||
|
|
2b39c5190b | ||
|
|
9c5f8b1de4 | ||
|
|
5469045b5a | ||
|
|
56665cd77a | ||
|
|
011f749786 | ||
|
|
5b235def37 | ||
|
|
593a4f0d9c | ||
|
|
d4e6a3d73d | ||
|
|
0aa7d22094 | ||
|
|
5ea8136c13 | ||
|
|
4b7e6c983b | ||
|
|
8d84c0a1ba | ||
|
|
1f140af94a | ||
|
|
c1a173c8f7 | ||
|
|
974bf1cc35 | ||
|
|
0ace1d5c70 | ||
|
|
595ab89390 | ||
|
|
34571483eb | ||
|
|
062bac2138 | ||
|
|
8ee0dd2218 | ||
|
|
51697f017e | ||
|
|
e2c9f3480d | ||
|
|
40f552cb66 | ||
|
|
65b846f0c7 | ||
|
|
a3060322f9 | ||
|
|
428792ed1b | ||
|
|
0cbb7c9a3c | ||
|
|
fabc35e729 |
71
app/Console/AutoSendStatementEmailCommand.php
Normal file
71
app/Console/AutoSendStatementEmailCommand.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Webstatement\Jobs\AutoSendStatementEmailJob;
|
||||
|
||||
class AutoSendStatementEmailCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'webstatement:auto-send-email
|
||||
{--force : Force run even if already running}
|
||||
{--dry-run : Show what would be sent without actually sending}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Automatically send statement emails for available statements';
|
||||
|
||||
/**
|
||||
* Execute the console command untuk menjalankan auto send email
|
||||
*
|
||||
* Command ini akan:
|
||||
* 1. Dispatch AutoSendStatementEmailJob
|
||||
* 2. Log aktivitas command
|
||||
* 3. Handle dry-run mode untuk testing
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$this->info('Starting auto send statement email process...');
|
||||
|
||||
Log::info('AutoSendStatementEmailCommand: Command started', [
|
||||
'force' => $this->option('force'),
|
||||
'dry_run' => $this->option('dry-run')
|
||||
]);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('DRY RUN MODE: Would dispatch AutoSendStatementEmailJob');
|
||||
Log::info('AutoSendStatementEmailCommand: Dry run mode, job not dispatched');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Dispatch job
|
||||
AutoSendStatementEmailJob::dispatch();
|
||||
|
||||
$this->info('AutoSendStatementEmailJob dispatched successfully');
|
||||
|
||||
Log::info('AutoSendStatementEmailCommand: Job dispatched successfully');
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: ' . $e->getMessage());
|
||||
|
||||
Log::error('AutoSendStatementEmailCommand: Command failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
app/Helpers/helpers.php
Normal file
96
app/Helpers/helpers.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Webstatement\Models\Account;
|
||||
use Modules\Webstatement\Models\ProvinceCore;
|
||||
|
||||
if(!function_exists('calculatePeriodDates')) {
|
||||
/**
|
||||
* Fungsi untuk menghitung tanggal periode berdasarkan periode yang diberikan
|
||||
* Jika periode 202505, mulai dari tanggal 9 sampai akhir bulan
|
||||
* Jika periode lain, mulai dari tanggal 1 sampai akhir bulan
|
||||
*/
|
||||
function calculatePeriodDates($period)
|
||||
{
|
||||
$year = substr($period, 0, 4);
|
||||
$month = substr($period, 4, 2);
|
||||
|
||||
// Log untuk debugging
|
||||
Log::info('Calculating period dates', [
|
||||
'period' => $period,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
]);
|
||||
|
||||
if ($period === '202505') {
|
||||
// Untuk periode 202505, mulai dari tanggal 9
|
||||
$startDate = \Carbon\Carbon::createFromDate($year, $month, 9,'Asia/Jakarta');
|
||||
} else {
|
||||
// Untuk periode lain, mulai dari tanggal 1
|
||||
$startDate = \Carbon\Carbon::createFromDate($year, $month, 1,'Asia/Jakarta');
|
||||
}
|
||||
|
||||
// Tanggal akhir selalu akhir bulan
|
||||
$endDate = \Carbon\Carbon::createFromDate($year, $month, 1)->endOfMonth();
|
||||
|
||||
return [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if(!function_exists('getProvinceCoreName')){
|
||||
function getProvinceCoreName($code){
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
if(!function_exists('generatePassword')){
|
||||
function generatePassword(Account $account)
|
||||
{
|
||||
$customer = $account->customer;
|
||||
$accountNumber = $account->account_number;
|
||||
|
||||
// Get last 2 digits of account number
|
||||
$lastTwoDigits = substr($accountNumber, -2);
|
||||
|
||||
// Determine which date to use based on sector
|
||||
$dateToUse = null;
|
||||
|
||||
if ($customer && $customer->sector) {
|
||||
$firstDigitSector = substr($customer->sector, 0, 1);
|
||||
|
||||
if ($firstDigitSector === '1') {
|
||||
// Use date_of_birth if available, otherwise birth_incorp_date
|
||||
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
|
||||
} else {
|
||||
// Use birth_incorp_date for sector > 1
|
||||
$dateToUse = $customer->birth_incorp_date;
|
||||
}
|
||||
}
|
||||
|
||||
// If no date found, fallback to account number
|
||||
if (!$dateToUse) {
|
||||
Log::warning("No date found for account {$accountNumber}, using account number as password");
|
||||
return $accountNumber;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the date and format it
|
||||
$date = Carbon::parse($dateToUse);
|
||||
$day = $date->format('d');
|
||||
$month = $date->format('M'); // 3-letter month abbreviation
|
||||
$year = $date->format('Y');
|
||||
|
||||
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
|
||||
$password = $day . $month . $year . $lastTwoDigits;
|
||||
|
||||
return $password;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
|
||||
return $accountNumber; // Fallback to account number
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ class CombinePdfController extends Controller
|
||||
|
||||
try {
|
||||
// Generate password based on customer relation data
|
||||
$password = $this->generatePassword($account);
|
||||
$password = generatePassword($account);
|
||||
|
||||
// Dispatch job to combine PDFs or apply password protection
|
||||
CombinePdfJob::dispatch($pdfFiles, $outputDir, $outputFilename, $password, $output_destination, $branchCode, $period);
|
||||
@@ -158,58 +158,4 @@ class CombinePdfController extends Controller
|
||||
'period' => $period
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate password based on customer relation data
|
||||
* Format: date+end 2 digit account_number
|
||||
* Example: 05Oct202585
|
||||
*
|
||||
* @param Account $account
|
||||
* @return string
|
||||
*/
|
||||
private function generatePassword(Account $account)
|
||||
{
|
||||
$customer = $account->customer;
|
||||
$accountNumber = $account->account_number;
|
||||
|
||||
// Get last 2 digits of account number
|
||||
$lastTwoDigits = substr($accountNumber, -2);
|
||||
|
||||
// Determine which date to use based on sector
|
||||
$dateToUse = null;
|
||||
|
||||
if ($customer && $customer->sector) {
|
||||
$firstDigitSector = substr($customer->sector, 0, 1);
|
||||
|
||||
if ($firstDigitSector === '1') {
|
||||
// Use date_of_birth if available, otherwise birth_incorp_date
|
||||
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
|
||||
} else {
|
||||
// Use birth_incorp_date for sector > 1
|
||||
$dateToUse = $customer->birth_incorp_date;
|
||||
}
|
||||
}
|
||||
|
||||
// If no date found, fallback to account number
|
||||
if (!$dateToUse) {
|
||||
Log::warning("No date found for account {$accountNumber}, using account number as password");
|
||||
return $accountNumber;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the date and format it
|
||||
$date = Carbon::parse($dateToUse);
|
||||
$day = $date->format('d');
|
||||
$month = $date->format('M'); // 3-letter month abbreviation
|
||||
$year = $date->format('Y');
|
||||
|
||||
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
|
||||
$password = $day . $month . $year . $lastTwoDigits;
|
||||
|
||||
return $password;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
|
||||
return $accountNumber; // Fallback to account number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -6,7 +7,7 @@
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Log;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Webstatement\Jobs\{ProcessAccountDataJob,
|
||||
ProcessArrangementDataJob,
|
||||
ProcessAtmTransactionJob,
|
||||
@@ -22,7 +23,8 @@
|
||||
ProcessStmtNarrParamDataJob,
|
||||
ProcessTellerDataJob,
|
||||
ProcessTransactionDataJob,
|
||||
ProcessSectorDataJob};
|
||||
ProcessSectorDataJob,
|
||||
ProcessProvinceDataJob};
|
||||
|
||||
class MigrasiController extends Controller
|
||||
{
|
||||
@@ -42,7 +44,8 @@
|
||||
'atmTransaction' => ProcessAtmTransactionJob::class,
|
||||
'arrangement' => ProcessArrangementDataJob::class,
|
||||
'billDetail' => ProcessBillDetailDataJob::class,
|
||||
'sector' => ProcessSectorDataJob::class
|
||||
'sector' => ProcessSectorDataJob::class,
|
||||
'province' => ProcessProvinceDataJob::class
|
||||
];
|
||||
|
||||
private const PARAMETER_PROCESSES = [
|
||||
@@ -50,7 +53,8 @@
|
||||
'stmtNarrParam',
|
||||
'stmtNarrFormat',
|
||||
'ftTxnTypeCondition',
|
||||
'sector'
|
||||
'sector',
|
||||
'province'
|
||||
];
|
||||
|
||||
private const DATA_PROCESSES = [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,38 +21,71 @@ class PrintStatementRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'account_number' => ['required', 'string'],
|
||||
'branch_code' => ['required', 'string'],
|
||||
// account_number required jika stmt_sent_type tidak diisi atau kosong
|
||||
'account_number' => [
|
||||
function ($attribute, $value, $fail) {
|
||||
$stmtSentType = $this->input('stmt_sent_type');
|
||||
|
||||
// Jika stmt_sent_type kosong atau tidak ada, maka account_number wajib diisi
|
||||
if (empty($stmtSentType) || (is_array($stmtSentType) && count(array_filter($stmtSentType)) === 0)) {
|
||||
if (empty($value)) {
|
||||
$fail('Account number is required when statement type is not specified.');
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
'stmt_sent_type' => ['nullable', 'array'],
|
||||
'stmt_sent_type.*' => ['string', 'in:ALL,BY.EMAIL,BY.MAIL.TO.DOM.ADDR,BY.MAIL.TO.KTP.ADDR,NO.PRINT,PRINT'],
|
||||
'is_period_range' => ['sometimes', 'boolean'],
|
||||
'email' => ['nullable', 'email'],
|
||||
'email_sent_at' => ['nullable', 'timestamp'],
|
||||
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches'],
|
||||
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches,multi_account'],
|
||||
'batch_id' => ['nullable', 'string'],
|
||||
// Password wajib diisi jika request_type diisi
|
||||
'password' => [
|
||||
function ($attribute, $value, $fail) {
|
||||
$requestType = $this->input('stmt_sent_type');
|
||||
|
||||
// Jika request_type diisi, maka password wajib diisi
|
||||
if (!empty($requestType) && empty($value)) {
|
||||
$fail('Password is required when statement sent type is specified.');
|
||||
}
|
||||
}
|
||||
],
|
||||
'period_from' => [
|
||||
'required',
|
||||
'string',
|
||||
'regex:/^\d{6}$/', // YYYYMM format
|
||||
// Prevent duplicate requests with same account number and period
|
||||
function ($attribute, $value, $fail) {
|
||||
$query = Statement::where('account_number', $this->input('account_number'))
|
||||
->where('authorization_status', '!=', 'rejected')
|
||||
->where('is_available', true)
|
||||
->where('period_from', $value);
|
||||
// Hanya cek duplikasi jika account_number ada
|
||||
if (!empty($this->input('account_number'))) {
|
||||
$query = Statement::where('account_number', $this->input('account_number'))
|
||||
->where('authorization_status', '!=', 'rejected')
|
||||
->where(function($query) {
|
||||
$query->where('is_available', true)
|
||||
->orWhere('is_generated', true);
|
||||
})
|
||||
->where('user_id', $this->user()->id)
|
||||
->where('period_from', $value);
|
||||
|
||||
// If this is an update request, exclude the current record
|
||||
if ($this->route('statement')) {
|
||||
$query->where('id', '!=', $this->route('statement'));
|
||||
}
|
||||
// If this is an update request, exclude the current record
|
||||
if ($this->route('statement')) {
|
||||
$query->where('id', '!=', $this->route('statement'));
|
||||
}
|
||||
|
||||
// If period_to is provided, check for overlapping periods
|
||||
if ($this->input('period_to')) {
|
||||
$query->where(function ($q) use ($value) {
|
||||
$q->where('period_from', '<=', $this->input('period_to'))
|
||||
->where('period_to', '>=', $value);
|
||||
});
|
||||
}
|
||||
// If period_to is provided, check for overlapping periods
|
||||
if ($this->input('period_to')) {
|
||||
$query->where(function ($q) use ($value) {
|
||||
$q->where('period_from', '<=', $this->input('period_to'))
|
||||
->where('period_to', '>=', $value);
|
||||
});
|
||||
}
|
||||
|
||||
if ($query->exists()) {
|
||||
$fail('A statement request with this account number and period already exists.');
|
||||
if ($query->exists()) {
|
||||
$fail('A statement request with this account number and period already exists.');
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -77,13 +110,17 @@ class PrintStatementRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'account_number.required' => 'Account number is required',
|
||||
'branch_code.required' => 'Branch code is required',
|
||||
'branch_code.string' => 'Branch code must be a string',
|
||||
'account_number.required' => 'Account number is required when statement type is not specified',
|
||||
'stmt_sent_type.*.in' => 'Invalid statement type selected',
|
||||
'period_from.required' => 'Period is required',
|
||||
'period_from.regex' => 'Period must be in YYYYMM format',
|
||||
'period_to.required' => 'End period is required for period range',
|
||||
'period_to.regex' => 'End period must be in YYYYMM format',
|
||||
'period_to.gte' => 'End period must be after or equal to start period',
|
||||
'request_type.in' => 'Request type must be single_account, branch, or all_branches',
|
||||
'request_type.in' => 'Request type must be single_account, branch, all_branches, or multi_account',
|
||||
'password.required' => 'Password is required when statement sent type is specified',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
348
app/Jobs/AutoSendStatementEmailJob.php
Normal file
348
app/Jobs/AutoSendStatementEmailJob.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Exception;
|
||||
use ZipArchive;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Modules\Webstatement\Models\Account;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Modules\Webstatement\Mail\StatementEmail;
|
||||
use Modules\Webstatement\Models\PrintStatementLog;
|
||||
|
||||
class AutoSendStatementEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Timeout untuk job dalam detik (10 menit)
|
||||
*/
|
||||
public $timeout = 600;
|
||||
|
||||
/**
|
||||
* Jumlah maksimal retry jika job gagal
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
// Constructor kosong karena job ini tidak memerlukan parameter
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job untuk mengirim email statement secara otomatis
|
||||
*
|
||||
* Job ini akan:
|
||||
* 1. Mencari statement yang siap dikirim (is_available/is_generated = true, email_sent_at = null)
|
||||
* 2. Memvalidasi keberadaan email
|
||||
* 3. Mengirim email dengan attachment PDF
|
||||
* 4. Update status email_sent_at
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
Log::info('AutoSendStatementEmailJob: Memulai proses auto send email');
|
||||
|
||||
// Ambil statement yang siap dikirim email
|
||||
$statements = $this->getPendingEmailStatements();
|
||||
|
||||
Log::info($statements);
|
||||
if ($statements->isEmpty()) {
|
||||
Log::info('AutoSendStatementEmailJob: Tidak ada statement yang perlu dikirim email');
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: Ditemukan statement untuk dikirim', [
|
||||
'count' => $statements->count(),
|
||||
'statement_ids' => $statements->pluck('id')->toArray()
|
||||
]);
|
||||
|
||||
// Proses setiap statement
|
||||
foreach ($statements as $statement) {
|
||||
$this->processSingleStatement($statement);
|
||||
}
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: Selesai memproses semua statement');
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('AutoSendStatementEmailJob: Error dalam proses auto send email', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mengambil statement yang siap untuk dikirim email
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
private function getPendingEmailStatements()
|
||||
{
|
||||
return PrintStatementLog::where(function ($query) {
|
||||
// Statement yang sudah available atau generated
|
||||
$query->where('is_available', true)
|
||||
->orWhere('is_generated', true);
|
||||
})
|
||||
->whereNotNull('email') // Harus ada email
|
||||
->where('email', '!=', '') // Email tidak kosong
|
||||
->whereNull('email_sent_at') // Belum pernah dikirim
|
||||
->whereNull('deleted_at') // Tidak soft deleted
|
||||
->orderBy('created_at', 'desc') // Prioritas yang lama dulu
|
||||
->limit(1) // Batasi maksimal 50 per run untuk performa
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses pengiriman email untuk satu statement
|
||||
*
|
||||
* @param PrintStatementLog $statement
|
||||
*/
|
||||
private function processSingleStatement(PrintStatementLog $statement): void
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
Log::info('AutoSendStatementEmailJob: Memproses statement', [
|
||||
'statement_id' => $statement->id,
|
||||
'account_number' => $statement->account_number,
|
||||
'email' => $statement->email
|
||||
]);
|
||||
|
||||
// Inisialisasi disk local dan SFTP
|
||||
$localDisk = Storage::disk('local');
|
||||
$sftpDisk = Storage::disk('sftpStatement');
|
||||
|
||||
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
|
||||
|
||||
/**
|
||||
* Fungsi helper untuk mendapatkan file dari disk dengan prioritas local
|
||||
* @param string $path - Path file yang dicari
|
||||
* @return array - [disk, exists, content]
|
||||
*/
|
||||
$getFileFromDisk = function($path) use ($localDisk, $sftpDisk) {
|
||||
// Cek di local disk terlebih dahulu
|
||||
if ($localDisk->exists("statements/{$path}")) {
|
||||
Log::info('AutoSendStatementEmailJob: File found in local disk', ['path' => "statements/{$path}"]);
|
||||
return [
|
||||
'disk' => $localDisk,
|
||||
'exists' => true,
|
||||
'path' => "statements/{$path}",
|
||||
'source' => 'local'
|
||||
];
|
||||
}
|
||||
|
||||
// Jika tidak ada di local, cek di SFTP
|
||||
if ($sftpDisk->exists($path)) {
|
||||
Log::info('AutoSendStatementEmailJob: File found in SFTP disk', ['path' => $path]);
|
||||
return [
|
||||
'disk' => $sftpDisk,
|
||||
'exists' => true,
|
||||
'path' => $path,
|
||||
'source' => 'sftp'
|
||||
];
|
||||
}
|
||||
|
||||
Log::warning('AutoSendStatementEmailJob: File not found in any disk', ['path' => $path]);
|
||||
return [
|
||||
'disk' => null,
|
||||
'exists' => false,
|
||||
'path' => $path,
|
||||
'source' => 'none'
|
||||
];
|
||||
};
|
||||
|
||||
if ($statement->is_period_range && $statement->period_to) {
|
||||
$this->processMultiPeriodStatement($statement, $getFileFromDisk);
|
||||
} else {
|
||||
$this->processSinglePeriodStatement($statement, $getFileFromDisk);
|
||||
}
|
||||
|
||||
// Update statement record to mark as emailed
|
||||
$statement->update([
|
||||
'email_sent_at' => now(),
|
||||
'updated_by' => 1 // System user ID, bisa disesuaikan
|
||||
]);
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: Email berhasil dikirim', [
|
||||
'statement_id' => $statement->id,
|
||||
'email' => $statement->email
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('AutoSendStatementEmailJob: Gagal mengirim email untuk statement', [
|
||||
'statement_id' => $statement->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Jangan throw exception untuk statement individual agar tidak menghentikan proses lainnya
|
||||
// Hanya log error saja
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses statement dengan multiple period (range)
|
||||
*
|
||||
* @param PrintStatementLog $statement
|
||||
* @param callable $getFileFromDisk
|
||||
*/
|
||||
private function processMultiPeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void
|
||||
{
|
||||
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
|
||||
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
|
||||
|
||||
// Loop through each month in the range
|
||||
$missingPeriods = [];
|
||||
$availablePeriods = [];
|
||||
$periodFiles = []; // Menyimpan info file untuk setiap periode
|
||||
|
||||
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
|
||||
$periodFormatted = $period->format('Ym');
|
||||
$periodPath = "{$periodFormatted}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
|
||||
|
||||
$fileInfo = $getFileFromDisk($periodPath);
|
||||
|
||||
if ($fileInfo['exists']) {
|
||||
$availablePeriods[] = $periodFormatted;
|
||||
$periodFiles[$periodFormatted] = $fileInfo;
|
||||
} else {
|
||||
$missingPeriods[] = $periodFormatted;
|
||||
}
|
||||
}
|
||||
|
||||
// If any period is available, create a zip and send it
|
||||
if (count($availablePeriods) > 0) {
|
||||
// Create a temporary zip file
|
||||
$zipFileName = "{$statement->account_number}_{$statement->period_from}_to_{$statement->period_to}.zip";
|
||||
$zipFilePath = storage_path("app/temp/{$zipFileName}");
|
||||
|
||||
// Ensure the temp directory exists
|
||||
if (!file_exists(storage_path('app/temp'))) {
|
||||
mkdir(storage_path('app/temp'), 0755, true);
|
||||
}
|
||||
|
||||
// Create a new zip archive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||||
// Add each available statement to the zip
|
||||
foreach ($availablePeriods as $period) {
|
||||
$fileInfo = $periodFiles[$period];
|
||||
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
|
||||
|
||||
// Download/copy the file to local temp storage
|
||||
file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path']));
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: File retrieved for zip', [
|
||||
'period' => $period,
|
||||
'source' => $fileInfo['source'],
|
||||
'path' => $fileInfo['path']
|
||||
]);
|
||||
|
||||
// Add the file to the zip
|
||||
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Send email with zip attachment
|
||||
Mail::to($statement->email)
|
||||
->send(new StatementEmail($statement, $zipFilePath, true));
|
||||
|
||||
// Clean up temporary files
|
||||
foreach ($availablePeriods as $period) {
|
||||
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
|
||||
if (file_exists($localFilePath)) {
|
||||
unlink($localFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the zip file after sending
|
||||
if (file_exists($zipFilePath)) {
|
||||
unlink($zipFilePath);
|
||||
}
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: Multi-period statement email sent successfully', [
|
||||
'statement_id' => $statement->id,
|
||||
'periods' => $availablePeriods,
|
||||
'sources' => array_map(fn($p) => $periodFiles[$p]['source'], $availablePeriods)
|
||||
]);
|
||||
} else {
|
||||
throw new Exception('Failed to create zip archive for email.');
|
||||
}
|
||||
} else {
|
||||
throw new Exception('No statements available for sending.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses statement dengan single period
|
||||
*
|
||||
* @param PrintStatementLog $statement
|
||||
* @param callable $getFileFromDisk
|
||||
*/
|
||||
private function processSinglePeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void
|
||||
{
|
||||
$account = Account::where('account_number',$statement->account_number)->first();
|
||||
$filePath = "{$statement->period_from}/{$account->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
|
||||
$fileInfo = $getFileFromDisk($filePath);
|
||||
|
||||
if ($fileInfo['exists']) {
|
||||
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$statement->period_from}.pdf");
|
||||
|
||||
// Ensure the temp directory exists
|
||||
if (!file_exists(storage_path('app/temp'))) {
|
||||
mkdir(storage_path('app/temp'), 0755, true);
|
||||
}
|
||||
|
||||
// Download/copy the file to local temp storage
|
||||
file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path']));
|
||||
|
||||
Log::info('AutoSendStatementEmailJob: Single period file retrieved', [
|
||||
'source' => $fileInfo['source'],
|
||||
'path' => $fileInfo['path']
|
||||
]);
|
||||
|
||||
// Send email with PDF attachment
|
||||
Mail::to($statement->email)
|
||||
->send(new StatementEmail($statement, $localFilePath, false));
|
||||
|
||||
// Delete the temporary file
|
||||
if (file_exists($localFilePath)) {
|
||||
unlink($localFilePath);
|
||||
}
|
||||
} else {
|
||||
throw new Exception('Statement file not found.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job failure
|
||||
*
|
||||
* @param Exception $exception
|
||||
*/
|
||||
public function failed(Exception $exception): void
|
||||
{
|
||||
Log::error('AutoSendStatementEmailJob: Job failed completely', [
|
||||
'error' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@
|
||||
'description' => $this->generateNarrative($item),
|
||||
'end_balance' => $runningBalance,
|
||||
'actual_date' => $actualDate,
|
||||
'recipt_no' => $item->ft?->recipt_no ?? '-',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
@@ -242,9 +243,9 @@
|
||||
$narr[] = $item->narrative;
|
||||
}
|
||||
|
||||
if ($item->ft?->recipt_no) {
|
||||
/*if ($item->ft?->recipt_no) {
|
||||
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
|
||||
}
|
||||
}*/
|
||||
|
||||
return implode(' ', array_filter($narr));
|
||||
}
|
||||
@@ -364,14 +365,38 @@
|
||||
? "statements/{$this->client}"
|
||||
: "statements";
|
||||
|
||||
$accountPath = "{$basePath}/{$this->account_number}";
|
||||
|
||||
// Create client directory if it doesn't exist
|
||||
if (!empty($this->client)) {
|
||||
// Di fungsi exportToCsv untuk basePath
|
||||
Storage::disk($this->disk)->makeDirectory($basePath);
|
||||
}
|
||||
// Tambahkan permission dan ownership setelah membuat directory
|
||||
if ($this->disk === 'local') {
|
||||
$fullPath = storage_path("app/{$basePath}");
|
||||
if (is_dir($fullPath)) {
|
||||
chmod($fullPath, 0777);
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp(dirname($fullPath), 'www-data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create account directory
|
||||
$accountPath = "{$basePath}/{$this->account_number}";
|
||||
Storage::disk($this->disk)->makeDirectory($accountPath);
|
||||
// Untuk accountPath
|
||||
Storage::disk($this->disk)->makeDirectory($accountPath);
|
||||
// Tambahkan permission dan ownership setelah membuat directory
|
||||
if ($this->disk === 'local') {
|
||||
$fullPath = storage_path("app/{$accountPath}");
|
||||
if (is_dir($fullPath)) {
|
||||
chmod($fullPath, 0777);
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp(dirname($fullPath), 'www-data');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$filePath = "{$accountPath}/{$this->fileName}";
|
||||
|
||||
@@ -380,7 +405,7 @@
|
||||
Storage::disk($this->disk)->delete($filePath);
|
||||
}
|
||||
|
||||
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE\n";
|
||||
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE|NO.RECEIPT\n";
|
||||
|
||||
// Ambil data yang sudah diproses dalam chunk untuk mengurangi penggunaan memori
|
||||
ProcessedStatement::where('account_number', $this->account_number)
|
||||
@@ -396,7 +421,8 @@
|
||||
$statement->transaction_type,
|
||||
$statement->description,
|
||||
$statement->end_balance,
|
||||
$statement->actual_date
|
||||
$statement->actual_date,
|
||||
$statement->recipt_no
|
||||
]) . "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,33 @@ namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Bus\{
|
||||
Queueable
|
||||
};
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\{
|
||||
InteractsWithQueue,
|
||||
SerializesModels
|
||||
};
|
||||
use Illuminate\Support\Facades\{
|
||||
DB,
|
||||
Log,
|
||||
Storage
|
||||
};
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Modules\Webstatement\Models\{
|
||||
PrintStatementLog,
|
||||
ProcessedStatement,
|
||||
StmtEntry,
|
||||
TempFundsTransfer,
|
||||
TempStmtNarrFormat,
|
||||
TempStmtNarrParam,
|
||||
Account,
|
||||
Customer
|
||||
};
|
||||
use Modules\Basicdata\Models\Branch;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Webstatement\Models\ProcessedStatement;
|
||||
use Modules\Webstatement\Models\StmtEntry;
|
||||
use Modules\Webstatement\Models\TempFundsTransfer;
|
||||
use Modules\Webstatement\Models\TempStmtNarrFormat;
|
||||
use Modules\Webstatement\Models\TempStmtNarrParam;
|
||||
use Owenoj\PDFPasswordProtect\Facade\PDFPasswordProtect;
|
||||
|
||||
class ExportStatementPeriodJob implements ShouldQueue
|
||||
{
|
||||
@@ -31,6 +45,8 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
protected $chunkSize = 1000;
|
||||
protected $startDate;
|
||||
protected $endDate;
|
||||
protected $toCsv;
|
||||
protected $statementId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
@@ -41,14 +57,16 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
* @param string $client
|
||||
* @param string $disk
|
||||
*/
|
||||
public function __construct(string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local')
|
||||
public function __construct(int $statementId, string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local', bool $toCsv = true)
|
||||
{
|
||||
$this->statementId = $statementId;
|
||||
$this->account_number = $account_number;
|
||||
$this->period = $period;
|
||||
$this->saldo = $saldo;
|
||||
$this->disk = $disk;
|
||||
$this->client = $client;
|
||||
$this->fileName = "{$account_number}_{$period}.csv";
|
||||
$this->toCsv = $toCsv;
|
||||
|
||||
// Calculate start and end dates based on period
|
||||
$this->calculatePeriodDates();
|
||||
@@ -64,7 +82,7 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
|
||||
// Special case for May 2025 - start from 12th
|
||||
if ($this->period === '202505') {
|
||||
$this->startDate = Carbon::createFromDate($year, $month, 12)->startOfDay();
|
||||
$this->startDate = Carbon::createFromDate($year, $month, 9)->startOfDay();
|
||||
} else {
|
||||
// For all other periods, start from 1st of the month
|
||||
$this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
|
||||
@@ -84,7 +102,12 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
Log::info("Date range: {$this->startDate->format('Y-m-d')} to {$this->endDate->format('Y-m-d')}");
|
||||
|
||||
$this->processStatementData();
|
||||
$this->exportToCsv();
|
||||
if($this->toCsv){
|
||||
$this->exportToCsv();
|
||||
}
|
||||
|
||||
// Generate PDF setelah data diproses
|
||||
$this->generatePdf();
|
||||
|
||||
Log::info("Export statement period job completed successfully for account: {$this->account_number}, period: {$this->period}");
|
||||
} catch (Exception $e) {
|
||||
@@ -112,12 +135,20 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
|
||||
private function getTotalEntryCount(): int
|
||||
{
|
||||
return StmtEntry::where('account_number', $this->account_number)
|
||||
->whereBetween('date_time', [
|
||||
$this->startDate->format('ymdHi'),
|
||||
$this->endDate->format('ymdHi')
|
||||
])
|
||||
->count();
|
||||
$query = StmtEntry::where('account_number', $this->account_number)
|
||||
->whereBetween('booking_date', [
|
||||
$this->startDate->format('Ymd'),
|
||||
$this->endDate->format('Ymd')
|
||||
]);
|
||||
|
||||
Log::info("Getting total entry count with query: " . $query->toSql(), [
|
||||
'bindings' => $query->getBindings(),
|
||||
'account' => $this->account_number,
|
||||
'start_date' => $this->startDate->format('Ymd'),
|
||||
'end_date' => $this->endDate->format('Ymd')
|
||||
]);
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
private function getExistingProcessedCount(array $criteria): int
|
||||
@@ -141,11 +172,11 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
|
||||
Log::info("Processing {$totalCount} statement entries for account: {$this->account_number}");
|
||||
|
||||
StmtEntry::with(['ft', 'transaction'])
|
||||
$entry = StmtEntry::with(['ft', 'transaction'])
|
||||
->where('account_number', $this->account_number)
|
||||
->whereBetween('date_time', [
|
||||
$this->startDate->format('ymdHi'),
|
||||
$this->endDate->format('ymdHi')
|
||||
->whereBetween('booking_date', [
|
||||
$this->startDate->format('Ymd'),
|
||||
$this->endDate->format('Ymd')
|
||||
])
|
||||
->orderBy('date_time', 'ASC')
|
||||
->orderBy('trans_reference', 'ASC')
|
||||
@@ -156,6 +187,13 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
DB::table('processed_statements')->insert($processedData);
|
||||
}
|
||||
});
|
||||
|
||||
if($entry){
|
||||
$printLog = PrintStatementLog::find($this->statementId);
|
||||
if($printLog){
|
||||
$printLog->update(['is_generated' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareProcessedData($entries, &$runningBalance, &$globalSequence): array
|
||||
@@ -166,14 +204,13 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
$globalSequence++;
|
||||
$runningBalance += (float) $item->amount_lcy;
|
||||
|
||||
$transactionDate = $this->formatTransactionDate($item);
|
||||
$actualDate = $this->formatActualDate($item);
|
||||
|
||||
$processedData[] = [
|
||||
'account_number' => $this->account_number,
|
||||
'period' => $this->period,
|
||||
'sequence_no' => $globalSequence,
|
||||
'transaction_date' => $transactionDate,
|
||||
'transaction_date' => $item->booking_date,
|
||||
'reference_number' => $item->trans_reference,
|
||||
'transaction_amount' => $item->amount_lcy,
|
||||
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
|
||||
@@ -378,26 +415,232 @@ class ExportStatementPeriodJob implements ShouldQueue
|
||||
return str_replace('<NL>', ' ', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF statement untuk account yang diproses
|
||||
* Menggunakan data yang sudah diproses dari ProcessedStatement
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
private function generatePdf(): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('ExportStatementPeriodJob: Memulai generate PDF', [
|
||||
'account_number' => $this->account_number,
|
||||
'period' => $this->period,
|
||||
'statement_id' => $this->statementId
|
||||
]);
|
||||
|
||||
// Ambil data account dan customer
|
||||
$account = Account::where('account_number', $this->account_number)->first();
|
||||
if (!$account) {
|
||||
throw new Exception("Account tidak ditemukan: {$this->account_number}");
|
||||
}
|
||||
|
||||
$customer = Customer::where('customer_code', $account->customer_code)->first();
|
||||
if (!$customer) {
|
||||
throw new Exception("Customer tidak ditemukan untuk account: {$this->account_number}");
|
||||
}
|
||||
|
||||
// Ambil data branch
|
||||
$branch = Branch::where('code', $account->branch_code)->first();
|
||||
if (!$branch) {
|
||||
throw new Exception("Branch tidak ditemukan: {$account->branch_code}");
|
||||
}
|
||||
|
||||
// Ambil statement entries yang sudah diproses
|
||||
$stmtEntries = ProcessedStatement::where('account_number', $this->account_number)
|
||||
->where('period', $this->period)
|
||||
->orderBy('sequence_no')
|
||||
->get();
|
||||
|
||||
if ($stmtEntries->isEmpty()) {
|
||||
throw new Exception("Tidak ada data statement yang diproses untuk account: {$this->account_number}");
|
||||
}
|
||||
|
||||
// Prepare header table background (convert to base64 if needed)
|
||||
$headerImagePath = public_path('assets/media/images/bg-header-table.png');
|
||||
$headerTableBg = file_exists($headerImagePath)
|
||||
? base64_encode(file_get_contents($headerImagePath))
|
||||
: null;
|
||||
|
||||
// Hitung saldo awal bulan
|
||||
$saldoAwalBulan = (object) ['actual_balance' => (float) $this->saldo];
|
||||
|
||||
// Generate filename
|
||||
$filename = "{$this->account_number}_{$this->period}.pdf";
|
||||
|
||||
// Tentukan path storage
|
||||
$storagePath = "statements/{$this->period}/{$account->branch_code}";
|
||||
$tempPath = storage_path("app/temp/{$filename}");
|
||||
$fullStoragePath = "{$storagePath}/{$filename}";
|
||||
|
||||
// Buat direktori temp jika belum ada
|
||||
if (!is_dir(dirname($tempPath))) {
|
||||
mkdir(dirname($tempPath), 0777, true);
|
||||
// Tambahkan pengecekan function dan error handling
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown(dirname($tempPath), 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp(dirname($tempPath), 'www-data');
|
||||
}
|
||||
}
|
||||
|
||||
// Pastikan direktori storage ada
|
||||
Storage::makeDirectory($storagePath);
|
||||
// Tambahkan permission dan ownership setelah membuat directory
|
||||
if ($this->disk === 'local') {
|
||||
$fullPath = storage_path("app/{$storagePath}");
|
||||
if (is_dir($fullPath)) {
|
||||
chmod($fullPath, 0777);
|
||||
// Tambahkan pengecekan function dan error handling
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown($fullPath, 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp($fullPath, 'www-data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$period = $this->period;
|
||||
|
||||
// Render HTML view
|
||||
$html = view('webstatement::statements.stmt', compact(
|
||||
'stmtEntries',
|
||||
'account',
|
||||
'customer',
|
||||
'headerTableBg',
|
||||
'branch',
|
||||
'period',
|
||||
'saldoAwalBulan'
|
||||
))->render();
|
||||
|
||||
Log::info('ExportStatementPeriodJob: HTML view berhasil di-render', [
|
||||
'account_number' => $this->account_number,
|
||||
'html_length' => strlen($html)
|
||||
]);
|
||||
|
||||
|
||||
// Di dalam fungsi generatePdf(), setelah Browsershot::html()->save($tempPath)
|
||||
// Generate PDF menggunakan Browsershot
|
||||
Browsershot::html($html)
|
||||
->showBackground()
|
||||
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
|
||||
->setOption('protocolTimeout', 2147483) // 2 menit timeout
|
||||
->setOption('headless', true)
|
||||
->noSandbox()
|
||||
->format('A4')
|
||||
->margins(0, 0, 0, 0)
|
||||
->waitUntil('load')
|
||||
->waitUntilNetworkIdle()
|
||||
->timeout(2147483)
|
||||
->save($tempPath);
|
||||
|
||||
// Verifikasi file berhasil dibuat
|
||||
if (!file_exists($tempPath)) {
|
||||
throw new Exception('PDF file gagal dibuat');
|
||||
}
|
||||
|
||||
$printLog = PrintStatementLog::find($this->statementId);
|
||||
|
||||
// Apply password protection jika diperlukan
|
||||
$password = $printLog->password ?? generatePassword($account); // Ambil dari config atau set default
|
||||
if (!empty($password)) {
|
||||
$tempProtectedPath = storage_path("app/temp/protected_{$filename}");
|
||||
|
||||
// Encrypt PDF dengan password
|
||||
PDFPasswordProtect::encrypt($tempPath, $tempProtectedPath, $password);
|
||||
|
||||
// Ganti file original dengan yang sudah diproteksi
|
||||
if (file_exists($tempProtectedPath)) {
|
||||
unlink($tempPath); // Hapus file original
|
||||
rename($tempProtectedPath, $tempPath); // Rename protected file ke original path
|
||||
|
||||
Log::info('ExportStatementPeriodJob: PDF password protection applied', [
|
||||
'account_number' => $this->account_number,
|
||||
'period' => $this->period
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$fileSize = filesize($tempPath);
|
||||
|
||||
// Pindahkan file ke storage permanen
|
||||
$pdfContent = file_get_contents($tempPath);
|
||||
Storage::put($fullStoragePath, $pdfContent);
|
||||
|
||||
// Update print statement log
|
||||
|
||||
if ($printLog) {
|
||||
$printLog->update([
|
||||
'is_available' => true,
|
||||
'is_generated' => true,
|
||||
'pdf_path' => $fullStoragePath,
|
||||
'file_size' => $fileSize
|
||||
]);
|
||||
}
|
||||
|
||||
// Hapus file temporary
|
||||
if (file_exists($tempPath)) {
|
||||
unlink($tempPath);
|
||||
}
|
||||
|
||||
Log::info('ExportStatementPeriodJob: PDF berhasil dibuat dan disimpan', [
|
||||
'account_number' => $this->account_number,
|
||||
'period' => $this->period,
|
||||
'storage_path' => $fullStoragePath,
|
||||
'file_size' => $fileSize,
|
||||
'statement_id' => $this->statementId
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('ExportStatementPeriodJob: Gagal generate PDF', [
|
||||
'error' => $e->getMessage(),
|
||||
'account_number' => $this->account_number,
|
||||
'period' => $this->period,
|
||||
'statement_id' => $this->statementId,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Update print statement log dengan status error
|
||||
$printLog = PrintStatementLog::find($this->statementId);
|
||||
if ($printLog) {
|
||||
$printLog->update([
|
||||
'is_available' => false,
|
||||
'error_message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
throw new Exception('Gagal generate PDF: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export processed data to CSV file
|
||||
*/
|
||||
private function exportToCsv(): void
|
||||
{
|
||||
// Determine the base path based on client
|
||||
$basePath = !empty($this->client)
|
||||
? "statements/{$this->client}"
|
||||
: "statements";
|
||||
$account = Account::where('account_number', $this->account_number)->first();
|
||||
|
||||
// Create client directory if it doesn't exist
|
||||
if (!empty($this->client)) {
|
||||
Storage::disk($this->disk)->makeDirectory($basePath);
|
||||
$storagePath = "statements/{$this->period}/{$account->branch_code}";
|
||||
Storage::disk($this->disk)->makeDirectory($storagePath);
|
||||
// Tambahkan permission dan ownership setelah membuat directory
|
||||
if ($this->disk === 'local') {
|
||||
$fullPath = storage_path("app/{$storagePath}");
|
||||
if (is_dir($fullPath)) {
|
||||
chmod($fullPath, 0777);
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp(dirname($fullPath), 'www-data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create account directory
|
||||
$accountPath = "{$basePath}/{$this->account_number}";
|
||||
Storage::disk($this->disk)->makeDirectory($accountPath);
|
||||
|
||||
$filePath = "{$accountPath}/{$this->fileName}";
|
||||
$filePath = "{$storagePath}/{$this->fileName}";
|
||||
|
||||
// Delete existing file if it exists
|
||||
if (Storage::disk($this->disk)->exists($filePath)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
769
app/Jobs/GenerateMultiAccountPdfJob.php
Normal file
769
app/Jobs/GenerateMultiAccountPdfJob.php
Normal file
@@ -0,0 +1,769 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Exception;
|
||||
use ZipArchive;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Support\Facades\{
|
||||
DB,
|
||||
Log,
|
||||
Storage
|
||||
};
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Modules\Basicdata\Models\Branch;
|
||||
use Illuminate\Queue\{
|
||||
SerializesModels,
|
||||
InteractsWithQueue
|
||||
};
|
||||
use Modules\Webstatement\Models\{
|
||||
StmtEntry,
|
||||
AccountBalance,
|
||||
PrintStatementLog,
|
||||
ProcessedStatement,
|
||||
TempStmtNarrParam,
|
||||
TempStmtNarrFormat
|
||||
};
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
||||
class GenerateMultiAccountPdfJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $statement;
|
||||
protected $accounts;
|
||||
protected $period;
|
||||
protected $clientName;
|
||||
protected $chunkSize = 10; // Process 10 accounts at a time
|
||||
protected $startDate;
|
||||
protected $endDate;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param PrintStatementLog $statement
|
||||
* @param \Illuminate\Database\Eloquent\Collection $accounts
|
||||
* @param string $period
|
||||
* @param string $clientName
|
||||
*/
|
||||
public function __construct($statement, $accounts, $period, $clientName)
|
||||
{
|
||||
$this->statement = $statement;
|
||||
$this->accounts = $accounts;
|
||||
$this->period = $period;
|
||||
$this->clientName = $clientName;
|
||||
|
||||
// Calculate period dates using same logic as ExportStatementPeriodJob
|
||||
$this->calculatePeriodDates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate start and end dates for the given period
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*/
|
||||
private function calculatePeriodDates(): void
|
||||
{
|
||||
$year = substr($this->period, 0, 4);
|
||||
$month = substr($this->period, 4, 2);
|
||||
|
||||
// Special case for May 2025 - start from 9th
|
||||
if ($this->period === '202505') {
|
||||
$this->startDate = Carbon::createFromDate($year, $month, 9)->startOfDay();
|
||||
} else {
|
||||
// For all other periods, start from 1st of the month
|
||||
$this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
|
||||
}
|
||||
|
||||
// End date is always the last day of the month
|
||||
$this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay();
|
||||
|
||||
Log::info('Period dates calculated for PDF generation', [
|
||||
'period' => $this->period,
|
||||
'start_date' => $this->startDate->format('Y-m-d'),
|
||||
'end_date' => $this->endDate->format('Y-m-d')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
|
||||
Log::info('Starting multi account PDF generation', [
|
||||
'statement_id' => $this->statement->id,
|
||||
'total_accounts' => $this->accounts->count(),
|
||||
'period' => $this->period,
|
||||
'date_range' => $this->startDate->format('Y-m-d') . ' to ' . $this->endDate->format('Y-m-d')
|
||||
]);
|
||||
|
||||
$pdfFiles = [];
|
||||
$successCount = 0;
|
||||
$failedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
// Process each account
|
||||
foreach ($this->accounts as $account) {
|
||||
try {
|
||||
$pdfPath = $this->generateAccountPdf($account);
|
||||
if ($pdfPath) {
|
||||
$pdfFiles[] = $pdfPath;
|
||||
$successCount++;
|
||||
|
||||
Log::info('PDF generated successfully for account', [
|
||||
'account_number' => $account->account_number,
|
||||
'pdf_path' => $pdfPath
|
||||
]);
|
||||
}
|
||||
|
||||
// Memory cleanup after each account
|
||||
gc_collect_cycles();
|
||||
} catch (Exception $e) {
|
||||
$failedCount++;
|
||||
$errors[] = [
|
||||
'account_number' => $account->account_number,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
|
||||
Log::error('Failed to generate PDF for account', [
|
||||
'account_number' => $account->account_number,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ZIP file if there are PDFs
|
||||
$zipPath = null;
|
||||
if (!empty($pdfFiles)) {
|
||||
$zipPath = $this->createZipFile($pdfFiles);
|
||||
}
|
||||
|
||||
// Update statement log
|
||||
$this->statement->update([
|
||||
'processed_accounts' => $this->accounts->count(),
|
||||
'success_count' => $successCount,
|
||||
'failed_count' => $failedCount,
|
||||
'status' => $failedCount > 0 ? 'completed_with_errors' : 'completed',
|
||||
'completed_at' => now(),
|
||||
'is_available' => $zipPath ? true : false,
|
||||
'is_generated' => $zipPath ? true : false,
|
||||
'error_message' => !empty($errors) ? json_encode($errors) : null
|
||||
]);
|
||||
|
||||
|
||||
Log::info('Multi account PDF generation completed', [
|
||||
'statement_id' => $this->statement->id,
|
||||
'success_count' => $successCount,
|
||||
'failed_count' => $failedCount,
|
||||
'zip_path' => $zipPath
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
Log::error('Multi account PDF generation failed', [
|
||||
'statement_id' => $this->statement->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Update statement with error status
|
||||
$this->statement->update([
|
||||
'status' => 'failed',
|
||||
'completed_at' => now(),
|
||||
'error_message' => $e->getMessage()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF untuk satu account
|
||||
* Menggunakan data dari ProcessedStatement yang sudah diproses oleh ExportStatementPeriodJob
|
||||
*
|
||||
* @param Account $account
|
||||
* @return string|null Path to generated PDF
|
||||
*/
|
||||
protected function generateAccountPdf($account)
|
||||
{
|
||||
try {
|
||||
// Prepare account query untuk processing
|
||||
$accountQuery = [
|
||||
'account_number' => $account->account_number,
|
||||
'period' => $this->period
|
||||
];
|
||||
|
||||
// Get total entry count
|
||||
$totalCount = $this->getTotalEntryCount($account->account_number);
|
||||
|
||||
// Delete existing processed data dan process ulang
|
||||
$this->deleteExistingProcessedData($accountQuery);
|
||||
$this->processAndSaveStatementEntries($account, $totalCount);
|
||||
|
||||
// Get statement entries from ProcessedStatement (data yang sudah diproses)
|
||||
$stmtEntries = $this->getProcessedStatementEntries($account->account_number);
|
||||
|
||||
// Get saldo awal bulan menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
$saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number);
|
||||
|
||||
// Get branch info
|
||||
$branch = Branch::where('code', $account->branch_code)->first();
|
||||
|
||||
// Prepare images for PDF
|
||||
$images = $this->prepareImagesForPdf();
|
||||
|
||||
$headerImagePath = public_path('assets/media/images/bg-header-table.png');
|
||||
$headerTableBg = file_exists($headerImagePath)
|
||||
? base64_encode(file_get_contents($headerImagePath))
|
||||
: null;
|
||||
|
||||
// Render HTML
|
||||
$html = view('webstatement::statements.stmt', [
|
||||
'stmtEntries' => $stmtEntries,
|
||||
'account' => $account,
|
||||
'customer' => $account->customer,
|
||||
'images' => $images,
|
||||
'branch' => $branch,
|
||||
'period' => $this->period,
|
||||
'saldoAwalBulan' => $saldoAwalBulan,
|
||||
'headerTableBg' => $headerTableBg,
|
||||
])->render();
|
||||
|
||||
// Generate PDF filename
|
||||
$filename = "statement_{$account->account_number}_{$this->period}_" . now()->format('YmdHis') . '.pdf';
|
||||
$storagePath = "statements/{$this->period}/multi_account/{$this->statement->id}";
|
||||
$fullStoragePath = "{$storagePath}/{$filename}";
|
||||
|
||||
// Ensure directory exists
|
||||
Storage::disk('local')->makeDirectory($storagePath);
|
||||
// Tambahkan permission dan ownership setelah membuat directory
|
||||
$fullPath = storage_path("app/{$storagePath}");
|
||||
if (is_dir($fullPath)) {
|
||||
chmod($fullPath, 0777);
|
||||
// Tambahkan pengecekan function dan error handling untuk chown
|
||||
if (function_exists('chown') && posix_getuid() === 0) {
|
||||
@chown($fullPath, 'www-data'); // Gunakan www-data instead of root
|
||||
@chgrp($fullPath, 'www-data');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDF path
|
||||
$pdfPath = storage_path("app/{$fullStoragePath}");
|
||||
|
||||
// Generate PDF using Browsershot
|
||||
Browsershot::html($html)
|
||||
->showBackground()
|
||||
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
|
||||
->setOption('protocolTimeout', 2147483) // 2 menit timeout
|
||||
->setOption('headless', true)
|
||||
->noSandbox()
|
||||
->format('A4')
|
||||
->margins(0, 0, 0, 0)
|
||||
->waitUntil('load')
|
||||
->waitUntilNetworkIdle()
|
||||
->timeout(2147483)
|
||||
->save($pdfPath);
|
||||
|
||||
// Verify file was created
|
||||
if (!file_exists($pdfPath)) {
|
||||
throw new Exception('PDF file was not created');
|
||||
}
|
||||
|
||||
// Clear variables to free memory
|
||||
unset($html, $stmtEntries, $images);
|
||||
|
||||
return $pdfPath;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate PDF for account', [
|
||||
'account_number' => $account->account_number,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total entry count untuk account
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param string $accountNumber
|
||||
* @return int
|
||||
*/
|
||||
protected function getTotalEntryCount($accountNumber): int
|
||||
{
|
||||
$query = StmtEntry::where('account_number', $accountNumber)
|
||||
->whereBetween('booking_date', [
|
||||
$this->startDate->format('Ymd'),
|
||||
$this->endDate->format('Ymd')
|
||||
]);
|
||||
|
||||
Log::info("Getting total entry count for PDF generation", [
|
||||
'account' => $accountNumber,
|
||||
'start_date' => $this->startDate->format('Ymd'),
|
||||
'end_date' => $this->endDate->format('Ymd')
|
||||
]);
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete existing processed data untuk account
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return void
|
||||
*/
|
||||
protected function deleteExistingProcessedData(array $criteria): void
|
||||
{
|
||||
Log::info('Deleting existing processed data for PDF generation', [
|
||||
'account_number' => $criteria['account_number'],
|
||||
'period' => $criteria['period']
|
||||
]);
|
||||
|
||||
ProcessedStatement::where('account_number', $criteria['account_number'])
|
||||
->where('period', $criteria['period'])
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process dan save statement entries untuk account
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param Account $account
|
||||
* @param int $totalCount
|
||||
* @return void
|
||||
*/
|
||||
protected function processAndSaveStatementEntries($account, int $totalCount): void
|
||||
{
|
||||
// Get saldo awal dari AccountBalance
|
||||
$saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number);
|
||||
$runningBalance = (float) $saldoAwalBulan->actual_balance;
|
||||
$globalSequence = 0;
|
||||
|
||||
Log::info("Processing {$totalCount} statement entries for PDF generation", [
|
||||
'account_number' => $account->account_number,
|
||||
'starting_balance' => $runningBalance
|
||||
]);
|
||||
|
||||
StmtEntry::with(['ft', 'transaction'])
|
||||
->where('account_number', $account->account_number)
|
||||
->whereBetween('booking_date', [
|
||||
$this->startDate->format('Ymd'),
|
||||
$this->endDate->format('Ymd')
|
||||
])
|
||||
->orderBy('date_time', 'ASC')
|
||||
->orderBy('trans_reference', 'ASC')
|
||||
->chunk(1000, function ($entries) use (&$runningBalance, &$globalSequence, $account) {
|
||||
$processedData = $this->prepareProcessedData($entries, $runningBalance, $globalSequence, $account->account_number);
|
||||
|
||||
if (!empty($processedData)) {
|
||||
DB::table('processed_statements')->insert($processedData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare processed data untuk batch insert
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param $entries
|
||||
* @param float $runningBalance
|
||||
* @param int $globalSequence
|
||||
* @param string $accountNumber
|
||||
* @return array
|
||||
*/
|
||||
protected function prepareProcessedData($entries, &$runningBalance, &$globalSequence, $accountNumber): array
|
||||
{
|
||||
$processedData = [];
|
||||
|
||||
foreach ($entries as $item) {
|
||||
$globalSequence++;
|
||||
$runningBalance += (float) $item->amount_lcy;
|
||||
|
||||
$actualDate = $this->formatActualDate($item);
|
||||
|
||||
$processedData[] = [
|
||||
'account_number' => $accountNumber,
|
||||
'period' => $this->period,
|
||||
'sequence_no' => $globalSequence,
|
||||
'transaction_date' => $item->booking_date,
|
||||
'reference_number' => $item->trans_reference,
|
||||
'transaction_amount' => $item->amount_lcy,
|
||||
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
|
||||
'description' => $this->generateNarrative($item),
|
||||
'end_balance' => $runningBalance,
|
||||
'actual_date' => $actualDate,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
return $processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format actual date dari item
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param $item
|
||||
* @return string
|
||||
*/
|
||||
protected function formatActualDate($item): string
|
||||
{
|
||||
try {
|
||||
$prefix = substr($item->trans_reference ?? '', 0, 2);
|
||||
$relationMap = [
|
||||
'FT' => 'ft',
|
||||
'TT' => 'tt',
|
||||
'DC' => 'dc',
|
||||
'AA' => 'aa'
|
||||
];
|
||||
|
||||
$datetime = $item->date_time;
|
||||
if (isset($relationMap[$prefix])) {
|
||||
$relation = $relationMap[$prefix];
|
||||
$datetime = $item->$relation?->date_time ?? $datetime;
|
||||
}
|
||||
|
||||
return Carbon::createFromFormat(
|
||||
'ymdHi',
|
||||
$datetime
|
||||
)->format('d/m/Y H:i');
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Error formatting actual date: " . $e->getMessage());
|
||||
return Carbon::now()->format('d/m/Y H:i');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate narrative untuk statement entry
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param $item
|
||||
* @return string
|
||||
*/
|
||||
protected function generateNarrative($item)
|
||||
{
|
||||
$narr = [];
|
||||
|
||||
if ($item->transaction) {
|
||||
if ($item->transaction->stmt_narr) {
|
||||
$narr[] = $item->transaction->stmt_narr;
|
||||
}
|
||||
if ($item->narrative) {
|
||||
$narr[] = $item->narrative;
|
||||
}
|
||||
if ($item->transaction->narr_type) {
|
||||
$narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item);
|
||||
}
|
||||
} else if ($item->narrative) {
|
||||
$narr[] = $item->narrative;
|
||||
}
|
||||
|
||||
if ($item->ft?->recipt_no) {
|
||||
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
|
||||
}
|
||||
|
||||
return implode(' ', array_filter($narr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted narrative berdasarkan narrative type
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param $narr
|
||||
* @param $item
|
||||
* @return string
|
||||
*/
|
||||
protected function getFormatNarrative($narr, $item)
|
||||
{
|
||||
|
||||
$narrParam = TempStmtNarrParam::where('_id', $narr)->first();
|
||||
|
||||
if (!$narrParam) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$fmt = '';
|
||||
if ($narrParam->_id == 'FTIN') {
|
||||
$fmt = 'FT.IN';
|
||||
} else if ($narrParam->_id == 'FTOUT') {
|
||||
$fmt = 'FT.OUT';
|
||||
} else if ($narrParam->_id == 'TTTRFOUT') {
|
||||
$fmt = 'TT.O.TRF';
|
||||
} else if ($narrParam->_id == 'TTTRFIN') {
|
||||
$fmt = 'TT.I.TRF';
|
||||
} else if ($narrParam->_id == 'APITRX'){
|
||||
$fmt = 'API.TSEL';
|
||||
} else if ($narrParam->_id == 'ONUSCR'){
|
||||
$fmt = 'ONUS.CR';
|
||||
} else if ($narrParam->_id == 'ONUSDR'){
|
||||
$fmt = 'ONUS.DR';
|
||||
}else {
|
||||
$fmt = $narrParam->_id;
|
||||
}
|
||||
|
||||
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
|
||||
|
||||
if (!$narrFormat) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the format string from the database
|
||||
$formatString = $narrFormat->text_data ?? '';
|
||||
|
||||
// Parse the format string
|
||||
// Split by the separator ']'
|
||||
$parts = explode(']', $formatString);
|
||||
|
||||
$result = '';
|
||||
|
||||
foreach ($parts as $index => $part) {
|
||||
if (empty($part)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($index === 0) {
|
||||
// For the first part, take only what's before the '!'
|
||||
$splitPart = explode('!', $part);
|
||||
if (count($splitPart) > 0) {
|
||||
// Remove quotes, backslashes, and other escape characters
|
||||
$cleanPart = trim($splitPart[0]).' ';
|
||||
// Remove quotes at the beginning and end
|
||||
$cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart);
|
||||
// Remove any remaining backslashes
|
||||
$cleanPart = str_replace('\\', '', $cleanPart);
|
||||
// Remove any remaining quotes
|
||||
$cleanPart = str_replace('"', '', $cleanPart);
|
||||
$result .= $cleanPart;
|
||||
}
|
||||
} else {
|
||||
// For other parts, these are field placeholders
|
||||
$fieldName = strtolower(str_replace('.', '_', $part));
|
||||
|
||||
// Get the corresponding parameter value from narrParam
|
||||
$paramValue = null;
|
||||
|
||||
// Check if the field exists as a property in narrParam
|
||||
if (property_exists($narrParam, $fieldName)) {
|
||||
$paramValue = $narrParam->$fieldName;
|
||||
} else if (isset($narrParam->$fieldName)) {
|
||||
$paramValue = $narrParam->$fieldName;
|
||||
}
|
||||
|
||||
// If we found a value, add it to the result
|
||||
if ($paramValue !== null) {
|
||||
$result .= $paramValue;
|
||||
} else {
|
||||
// If no value found, try to use the original field name as a fallback
|
||||
if ($fieldName !== 'recipt_no') {
|
||||
$prefix = substr($item->trans_reference ?? '', 0, 2);
|
||||
$relationMap = [
|
||||
'FT' => 'ft',
|
||||
'TT' => 'tt',
|
||||
'DC' => 'dc',
|
||||
'AA' => 'aa'
|
||||
];
|
||||
|
||||
if (isset($relationMap[$prefix])) {
|
||||
$relation = $relationMap[$prefix];
|
||||
$result .= ($item->$relation?->$fieldName ?? '') . ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('<NL>', ' ', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processed statement entries untuk account
|
||||
* Menggunakan data dari tabel ProcessedStatement yang sudah diproses
|
||||
*
|
||||
* @param string $accountNumber
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
protected function getProcessedStatementEntries($accountNumber)
|
||||
{
|
||||
Log::info('Getting processed statement entries', [
|
||||
'account_number' => $accountNumber,
|
||||
'period' => $this->period
|
||||
]);
|
||||
|
||||
return ProcessedStatement::where('account_number', $accountNumber)
|
||||
->where('period', $this->period)
|
||||
->orderBy('sequence_no', 'ASC')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saldo awal bulan untuk account
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param string $accountNumber
|
||||
* @return object
|
||||
*/
|
||||
protected function getSaldoAwalBulan($accountNumber)
|
||||
{
|
||||
// Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
// Ambil saldo dari ProcessedStatement entry pertama dikurangi transaction_amount
|
||||
$firstEntry = ProcessedStatement::where('account_number', $accountNumber)
|
||||
->where('period', $this->period)
|
||||
->orderBy('sequence_no', 'ASC')
|
||||
->first();
|
||||
|
||||
if ($firstEntry) {
|
||||
$saldoAwal = $firstEntry->end_balance - $firstEntry->transaction_amount;
|
||||
return (object) ['actual_balance' => $saldoAwal];
|
||||
}
|
||||
|
||||
// Fallback ke AccountBalance jika tidak ada ProcessedStatement
|
||||
$saldoPeriod = $this->calculateSaldoPeriod($this->period);
|
||||
|
||||
$saldo = AccountBalance::where('account_number', $accountNumber)
|
||||
->where('period', $saldoPeriod)
|
||||
->first();
|
||||
|
||||
return $saldo ?: (object) ['actual_balance' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate saldo period berdasarkan aturan bisnis
|
||||
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
|
||||
*
|
||||
* @param string $period
|
||||
* @return string
|
||||
*/
|
||||
protected function calculateSaldoPeriod($period)
|
||||
{
|
||||
if ($period === '202505') {
|
||||
return '20250510';
|
||||
}
|
||||
|
||||
// For periods after 202505, get last day of previous month
|
||||
if ($period > '202505') {
|
||||
$year = substr($period, 0, 4);
|
||||
$month = substr($period, 4, 2);
|
||||
$firstDay = Carbon::createFromFormat('Ym', $period)->startOfMonth();
|
||||
return $firstDay->copy()->subDay()->format('Ymd');
|
||||
}
|
||||
|
||||
return $period . '01';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare images as base64 for PDF
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function prepareImagesForPdf()
|
||||
{
|
||||
$images = [];
|
||||
|
||||
$imagePaths = [
|
||||
'headerTableBg' => 'assets/media/images/bg-header-table.png',
|
||||
'watermark' => 'assets/media/images/watermark.png',
|
||||
'logoArthagraha' => 'assets/media/images/logo-arthagraha.png',
|
||||
'logoAgi' => 'assets/media/images/logo-agi.png',
|
||||
'bannerFooter' => 'assets/media/images/banner-footer.png'
|
||||
];
|
||||
|
||||
foreach ($imagePaths as $key => $path) {
|
||||
$fullPath = public_path($path);
|
||||
if (file_exists($fullPath)) {
|
||||
$images[$key] = base64_encode(file_get_contents($fullPath));
|
||||
} else {
|
||||
$images[$key] = null;
|
||||
Log::warning('Image file not found', ['path' => $fullPath]);
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ZIP file dari multiple PDF files dengan password protection
|
||||
*
|
||||
* @param array $pdfFiles
|
||||
* @return string|null Path to ZIP file
|
||||
*/
|
||||
protected function createZipFile($pdfFiles)
|
||||
{
|
||||
try {
|
||||
$zipFilename = "statements_{$this->period}_multi_account_{$this->statement->id}_" . now()->format('YmdHis') . '.zip';
|
||||
$zipStoragePath = "statements/{$this->period}/multi_account/{$this->statement->id}";
|
||||
$fullZipPath = "{$zipStoragePath}/{$zipFilename}";
|
||||
|
||||
// Ensure directory exists
|
||||
Storage::disk('local')->makeDirectory($zipStoragePath);
|
||||
|
||||
$zipPath = storage_path("app/{$fullZipPath}");
|
||||
|
||||
// Get password from statement or use default
|
||||
$password = $this->statement->password ?? config('webstatement.zip_password', 'statement123');
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== TRUE) {
|
||||
throw new Exception('Cannot create ZIP file');
|
||||
}
|
||||
|
||||
// Set password for the ZIP file
|
||||
if (!empty($password)) {
|
||||
$zip->setPassword($password);
|
||||
Log::info('ZIP password protection enabled', [
|
||||
'statement_id' => $this->statement->id,
|
||||
'zip_path' => $zipPath
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($pdfFiles as $pdfFile) {
|
||||
if (file_exists($pdfFile)) {
|
||||
$filename = basename($pdfFile);
|
||||
$zip->addFile($pdfFile, $filename);
|
||||
|
||||
// Set encryption for each file in ZIP
|
||||
if (!empty($password)) {
|
||||
$zip->setEncryptionName($filename, ZipArchive::EM_AES_256);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Verify ZIP file was created
|
||||
if (!file_exists($zipPath)) {
|
||||
throw new Exception('ZIP file was not created');
|
||||
}
|
||||
|
||||
Log::info('ZIP file created successfully with password protection', [
|
||||
'zip_path' => $zipPath,
|
||||
'pdf_count' => count($pdfFiles),
|
||||
'statement_id' => $this->statement->id,
|
||||
'password_protected' => !empty($password)
|
||||
]);
|
||||
|
||||
// Clean up individual PDF files after creating ZIP
|
||||
foreach ($pdfFiles as $pdfFile) {
|
||||
if (file_exists($pdfFile)) {
|
||||
unlink($pdfFile);
|
||||
}
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to create ZIP file', [
|
||||
'error' => $e->getMessage(),
|
||||
'statement_id' => $this->statement->id
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
300
app/Jobs/ProcessProvinceDataJob.php
Normal file
300
app/Jobs/ProcessProvinceDataJob.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Webstatement\Models\ProvinceCore;
|
||||
|
||||
class ProcessProvinceDataJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const CSV_DELIMITER = '~';
|
||||
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
|
||||
private const FILENAME = 'ST.PROVINCE.csv';
|
||||
private const DISK_NAME = 'sftpStatement';
|
||||
|
||||
private string $period = '';
|
||||
private int $processedCount = 0;
|
||||
private int $errorCount = 0;
|
||||
private int $skippedCount = 0;
|
||||
|
||||
/**
|
||||
* Membuat instance job baru untuk memproses data provinsi
|
||||
*
|
||||
* @param string $period Periode data yang akan diproses
|
||||
*/
|
||||
public function __construct(string $period = '')
|
||||
{
|
||||
$this->period = $period;
|
||||
Log::info('ProcessProvinceDataJob: Job dibuat untuk periode: ' . $period);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menjalankan job untuk memproses file ST.PROVINCE.csv
|
||||
* Menggunakan transaction untuk memastikan konsistensi data
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
Log::info('ProcessProvinceDataJob: Memulai pemrosesan data provinsi');
|
||||
|
||||
$this->initializeJob();
|
||||
|
||||
if ($this->period === '') {
|
||||
Log::warning('ProcessProvinceDataJob: Tidak ada periode yang diberikan untuk pemrosesan data provinsi');
|
||||
DB::rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processPeriod();
|
||||
$this->logJobCompletion();
|
||||
|
||||
DB::commit();
|
||||
Log::info('ProcessProvinceDataJob: Transaction berhasil di-commit');
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollback();
|
||||
Log::error('ProcessProvinceDataJob: Error dalam pemrosesan, transaction di-rollback: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inisialisasi pengaturan job
|
||||
* Mengatur timeout dan reset counter
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function initializeJob(): void
|
||||
{
|
||||
set_time_limit(self::MAX_EXECUTION_TIME);
|
||||
$this->processedCount = 0;
|
||||
$this->errorCount = 0;
|
||||
$this->skippedCount = 0;
|
||||
|
||||
Log::info('ProcessProvinceDataJob: Job diinisialisasi dengan timeout ' . self::MAX_EXECUTION_TIME . ' detik');
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses file untuk periode tertentu
|
||||
* Mengambil file dari SFTP dan memproses data
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function processPeriod(): void
|
||||
{
|
||||
$disk = Storage::disk(self::DISK_NAME);
|
||||
$filePath = "$this->period/" . self::FILENAME;
|
||||
|
||||
Log::info('ProcessProvinceDataJob: Memproses periode ' . $this->period);
|
||||
|
||||
if (!$this->validateFile($disk, $filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
|
||||
$this->processFile($tempFilePath, $filePath);
|
||||
$this->cleanup($tempFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi keberadaan file di storage
|
||||
*
|
||||
* @param mixed $disk Storage disk instance
|
||||
* @param string $filePath Path file yang akan divalidasi
|
||||
* @return bool
|
||||
*/
|
||||
private function validateFile($disk, string $filePath): bool
|
||||
{
|
||||
Log::info("ProcessProvinceDataJob: Memvalidasi file provinsi: $filePath");
|
||||
|
||||
if (!$disk->exists($filePath)) {
|
||||
Log::warning("ProcessProvinceDataJob: File tidak ditemukan: $filePath");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log::info("ProcessProvinceDataJob: File ditemukan dan valid: $filePath");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Membuat file temporary untuk pemrosesan
|
||||
*
|
||||
* @param mixed $disk Storage disk instance
|
||||
* @param string $filePath Path file sumber
|
||||
* @return string Path file temporary
|
||||
*/
|
||||
private function createTemporaryFile($disk, string $filePath): string
|
||||
{
|
||||
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
|
||||
file_put_contents($tempFilePath, $disk->get($filePath));
|
||||
|
||||
Log::info("ProcessProvinceDataJob: File temporary dibuat: $tempFilePath");
|
||||
return $tempFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses file CSV dan mengimpor data ke database
|
||||
* Format CSV: id~date_time~province~province_name
|
||||
*
|
||||
* @param string $tempFilePath Path file temporary
|
||||
* @param string $filePath Path file asli untuk logging
|
||||
* @return void
|
||||
*/
|
||||
private function processFile(string $tempFilePath, string $filePath): void
|
||||
{
|
||||
$handle = fopen($tempFilePath, "r");
|
||||
if ($handle === false) {
|
||||
Log::error("ProcessProvinceDataJob: Tidak dapat membuka file: $filePath");
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info("ProcessProvinceDataJob: Memulai pemrosesan file: $filePath");
|
||||
|
||||
$rowCount = 0;
|
||||
$isFirstRow = true;
|
||||
|
||||
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
|
||||
$rowCount++;
|
||||
|
||||
// Skip header row
|
||||
if ($isFirstRow) {
|
||||
$isFirstRow = false;
|
||||
Log::info("ProcessProvinceDataJob: Melewati header row: " . implode(self::CSV_DELIMITER, $row));
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->processRow($row, $rowCount, $filePath);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
Log::info("ProcessProvinceDataJob: Selesai memproses $filePath. Total baris: $rowCount, Diproses: {$this->processedCount}, Error: {$this->errorCount}, Dilewati: {$this->skippedCount}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Memproses satu baris data CSV
|
||||
*
|
||||
* @param array $row Data baris CSV
|
||||
* @param int $rowCount Nomor baris untuk logging
|
||||
* @param string $filePath Path file untuk logging
|
||||
* @return void
|
||||
*/
|
||||
private function processRow(array $row, int $rowCount, string $filePath): void
|
||||
{
|
||||
// Validasi jumlah kolom (id~date_time~province~province_name = 4 kolom)
|
||||
if (count($row) !== 4) {
|
||||
Log::warning("ProcessProvinceDataJob: Baris $rowCount di $filePath memiliki jumlah kolom yang salah. Diharapkan: 4, Ditemukan: " . count($row));
|
||||
$this->skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Map data sesuai format CSV
|
||||
$data = [
|
||||
'code' => trim($row[2]), // province code
|
||||
'name' => trim($row[3]) // province_name
|
||||
];
|
||||
|
||||
Log::debug("ProcessProvinceDataJob: Memproses baris $rowCount dengan data: " . json_encode($data));
|
||||
|
||||
$this->saveRecord($data, $rowCount, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menyimpan record provinsi ke database
|
||||
* Menggunakan updateOrCreate untuk menghindari duplikasi
|
||||
*
|
||||
* @param array $data Data provinsi yang akan disimpan
|
||||
* @param int $rowCount Nomor baris untuk logging
|
||||
* @param string $filePath Path file untuk logging
|
||||
* @return void
|
||||
*/
|
||||
private function saveRecord(array $data, int $rowCount, string $filePath): void
|
||||
{
|
||||
try {
|
||||
// Validasi data wajib
|
||||
if (empty($data['code']) || empty($data['name'])) {
|
||||
Log::warning("ProcessProvinceDataJob: Baris $rowCount di $filePath memiliki data kosong. Code: '{$data['code']}', Name: '{$data['name']}'");
|
||||
$this->skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Simpan atau update data provinsi
|
||||
$province = ProvinceCore::updateOrCreate(
|
||||
['code' => $data['code']], // Kondisi pencarian
|
||||
['name' => $data['name']] // Data yang akan diupdate/insert
|
||||
);
|
||||
|
||||
$this->processedCount++;
|
||||
Log::debug("ProcessProvinceDataJob: Berhasil menyimpan provinsi ID: {$province->id}, Code: {$data['code']}, Name: {$data['name']}");
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->errorCount++;
|
||||
Log::error("ProcessProvinceDataJob: Error menyimpan data provinsi pada baris $rowCount di $filePath: " . $e->getMessage());
|
||||
Log::error("ProcessProvinceDataJob: Data yang error: " . json_encode($data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Membersihkan file temporary
|
||||
*
|
||||
* @param string $tempFilePath Path file temporary yang akan dihapus
|
||||
* @return void
|
||||
*/
|
||||
private function cleanup(string $tempFilePath): void
|
||||
{
|
||||
if (file_exists($tempFilePath)) {
|
||||
unlink($tempFilePath);
|
||||
Log::info("ProcessProvinceDataJob: File temporary dihapus: $tempFilePath");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging hasil akhir pemrosesan job
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function logJobCompletion(): void
|
||||
{
|
||||
$message = "ProcessProvinceDataJob: Pemrosesan data provinsi selesai. " .
|
||||
"Total diproses: {$this->processedCount}, " .
|
||||
"Total error: {$this->errorCount}, " .
|
||||
"Total dilewati: {$this->skippedCount}";
|
||||
|
||||
Log::info($message);
|
||||
|
||||
// Log summary untuk monitoring
|
||||
if ($this->errorCount > 0) {
|
||||
Log::warning("ProcessProvinceDataJob: Terdapat {$this->errorCount} error dalam pemrosesan");
|
||||
}
|
||||
|
||||
if ($this->skippedCount > 0) {
|
||||
Log::info("ProcessProvinceDataJob: Terdapat {$this->skippedCount} baris yang dilewati");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job failure
|
||||
*
|
||||
* @param Exception $exception
|
||||
* @return void
|
||||
*/
|
||||
public function failed(Exception $exception): void
|
||||
{
|
||||
Log::error('ProcessProvinceDataJob: Job gagal dijalankan: ' . $exception->getMessage());
|
||||
Log::error('ProcessProvinceDataJob: Stack trace: ' . $exception->getTraceAsString());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Mail;
|
||||
|
||||
use Carbon\Carbon;
|
||||
@@ -8,12 +7,13 @@
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Log;
|
||||
|
||||
use Modules\Webstatement\Models\Account;
|
||||
use Modules\Webstatement\Models\PrintStatementLog;
|
||||
use Symfony\Component\Mailer\Mailer;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StatementEmail extends Mailable
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Webstatement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Modules\Basicdata\Models\Branch;
|
||||
// use Modules\Webstatement\Database\Factories\AccountFactory;
|
||||
|
||||
class Account extends Model
|
||||
@@ -34,7 +35,7 @@ class Account extends Model
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_code', 'customer_code');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all balances for this account.
|
||||
*/
|
||||
@@ -42,10 +43,10 @@ class Account extends Model
|
||||
{
|
||||
return $this->hasMany(AccountBalance::class, 'account_number', 'account_number');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get balance for a specific period.
|
||||
*
|
||||
*
|
||||
* @param string $period Format: YYYY-MM
|
||||
* @return AccountBalance|null
|
||||
*/
|
||||
@@ -53,4 +54,8 @@ class Account extends Model
|
||||
{
|
||||
return $this->balances()->where('period', $period)->first();
|
||||
}
|
||||
|
||||
public function branch(){
|
||||
return $this->belongsTo(Branch::class, 'branch_code','code');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -44,11 +44,15 @@ class PrintStatementLog extends Model
|
||||
'remarks',
|
||||
'email',
|
||||
'email_sent_at',
|
||||
'stmt_sent_type',
|
||||
'is_generated',
|
||||
'password', // Tambahan field password
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_period_range' => 'boolean',
|
||||
'is_available' => 'boolean',
|
||||
'is_generated' => 'boolean',
|
||||
'is_downloaded' => 'boolean',
|
||||
'downloaded_at' => 'datetime',
|
||||
'authorized_at' => 'datetime',
|
||||
@@ -57,6 +61,10 @@ class PrintStatementLog extends Model
|
||||
'target_accounts' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', // Hide password dari serialization
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the formatted period display
|
||||
*
|
||||
@@ -285,4 +293,8 @@ class PrintStatementLog extends Model
|
||||
{
|
||||
return $query->where('request_type', 'single_account');
|
||||
}
|
||||
|
||||
public function account(){
|
||||
return $this->belongsTo(Account::class, 'account_number','account_number');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
'transaction_type',
|
||||
'description',
|
||||
'end_balance',
|
||||
'actual_date'
|
||||
'actual_date',
|
||||
'recipt_no'
|
||||
];
|
||||
}
|
||||
|
||||
161
app/Models/ProvinceCore.php
Normal file
161
app/Models/ProvinceCore.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvinceCore extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Nama tabel yang digunakan oleh model
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'province_core';
|
||||
|
||||
/**
|
||||
* Field yang dapat diisi secara mass assignment
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Field yang di-cast ke tipe data tertentu
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scope untuk mencari berdasarkan kode provinsi
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $code
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeByCode($query, $code)
|
||||
{
|
||||
Log::info('ProvinceCore: Mencari provinsi dengan kode: ' . $code);
|
||||
return $query->where('code', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope untuk mencari berdasarkan nama provinsi
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $name
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeByName($query, $name)
|
||||
{
|
||||
Log::info('ProvinceCore: Mencari provinsi dengan nama: ' . $name);
|
||||
return $query->where('name', 'ILIKE', '%' . $name . '%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope untuk mendapatkan semua provinsi yang aktif
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
Log::info('ProvinceCore: Mengambil semua provinsi aktif');
|
||||
return $query->orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mendapatkan provinsi berdasarkan kode
|
||||
*
|
||||
* @param string $code
|
||||
* @return ProvinceCore|null
|
||||
*/
|
||||
public static function getByCode($code)
|
||||
{
|
||||
try {
|
||||
Log::info('ProvinceCore: Mengambil provinsi dengan kode: ' . $code);
|
||||
return self::byCode($code)->first();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ProvinceCore: Error mengambil provinsi dengan kode ' . $code . ': ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mendapatkan semua provinsi untuk dropdown/select
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function getForDropdown()
|
||||
{
|
||||
try {
|
||||
Log::info('ProvinceCore: Mengambil data provinsi untuk dropdown');
|
||||
return self::active()->pluck('name', 'code');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ProvinceCore: Error mengambil data dropdown provinsi: ' . $e->getMessage());
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi kode provinsi
|
||||
*
|
||||
* @param string $code
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidCode($code)
|
||||
{
|
||||
try {
|
||||
Log::info('ProvinceCore: Validasi kode provinsi: ' . $code);
|
||||
return self::byCode($code)->exists();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ProvinceCore: Error validasi kode provinsi ' . $code . ': ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot method untuk model events
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
Log::info('ProvinceCore: Membuat data provinsi baru dengan kode: ' . $model->code);
|
||||
});
|
||||
|
||||
static::created(function ($model) {
|
||||
Log::info('ProvinceCore: Data provinsi berhasil dibuat dengan ID: ' . $model->id);
|
||||
});
|
||||
|
||||
static::updating(function ($model) {
|
||||
Log::info('ProvinceCore: Mengupdate data provinsi dengan ID: ' . $model->id);
|
||||
});
|
||||
|
||||
static::updated(function ($model) {
|
||||
Log::info('ProvinceCore: Data provinsi berhasil diupdate dengan ID: ' . $model->id);
|
||||
});
|
||||
|
||||
static::deleting(function ($model) {
|
||||
Log::info('ProvinceCore: Menghapus data provinsi dengan ID: ' . $model->id);
|
||||
});
|
||||
|
||||
static::deleted(function ($model) {
|
||||
Log::info('ProvinceCore: Data provinsi berhasil dihapus dengan ID: ' . $model->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use Modules\Webstatement\Console\ProcessDailyMigration;
|
||||
use Modules\Webstatement\Console\ExportPeriodStatements;
|
||||
use Modules\Webstatement\Console\UpdateAllAtmCardsCommand;
|
||||
use Modules\Webstatement\Console\CheckEmailProgressCommand;
|
||||
use Modules\Webstatement\Console\AutoSendStatementEmailCommand;
|
||||
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
|
||||
use Modules\Webstatement\Console\SendStatementEmailCommand;
|
||||
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
|
||||
@@ -72,7 +73,8 @@ class WebstatementServiceProvider extends ServiceProvider
|
||||
GenerateAtmTransactionReport::class,
|
||||
SendStatementEmailCommand::class,
|
||||
CheckEmailProgressCommand::class,
|
||||
UpdateAllAtmCardsCommand::class
|
||||
UpdateAllAtmCardsCommand::class,
|
||||
AutoSendStatementEmailCommand::class
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
301
app/Services/PHPMailerService.php
Normal file
301
app/Services/PHPMailerService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,7 @@
|
||||
|
||||
return [
|
||||
'name' => 'Webstatement',
|
||||
|
||||
// ZIP file password configuration
|
||||
'zip_password' => env('WEBSTATEMENT_ZIP_PASSWORD', 'statement123'),
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Menjalankan migrasi untuk membuat tabel province_core
|
||||
* Tabel ini menyimpan data master provinsi dengan kode dan nama
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
Schema::create('province_core', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 10)->unique()->comment('Kode provinsi unik');
|
||||
$table->string('name', 255)->comment('Nama provinsi');
|
||||
$table->timestamps();
|
||||
|
||||
// Index untuk performa pencarian
|
||||
$table->index(['code']);
|
||||
$table->index(['name']);
|
||||
});
|
||||
|
||||
DB::commit();
|
||||
Log::info('Migration province_core table berhasil dibuat');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
Log::error('Migration province_core table gagal: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Membalikkan migrasi dengan menghapus tabel province_core
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
Schema::dropIfExists('province_core');
|
||||
|
||||
DB::commit();
|
||||
Log::info('Migration rollback province_core table berhasil');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
Log::error('Migration rollback province_core table gagal: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Menjalankan migrasi untuk menambahkan kolom password ke tabel print_statement_logs
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('print_statement_logs', function (Blueprint $table) {
|
||||
// Menambahkan kolom password setelah kolom stmt_sent_type
|
||||
$table->string('password', 255)->nullable()->after('stmt_sent_type')
|
||||
->comment('Password untuk proteksi PDF statement');
|
||||
|
||||
// Menambahkan index untuk performa query jika diperlukan
|
||||
$table->index(['password'], 'idx_print_statement_logs_password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Membalikkan migrasi dengan menghapus kolom password
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('print_statement_logs', function (Blueprint $table) {
|
||||
// Hapus index terlebih dahulu
|
||||
$table->dropIndex('idx_print_statement_logs_password');
|
||||
|
||||
// Hapus kolom password
|
||||
$table->dropColumn('password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('processed_statements', function (Blueprint $table) {
|
||||
$table->string('recipt_no')->nullable()->after('reference_number');
|
||||
|
||||
// Menambahkan index untuk field no_receipt jika diperlukan untuk pencarian
|
||||
$table->index('recipt_no');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('processed_statements', function (Blueprint $table) {
|
||||
$table->dropIndex(['recipt_no']);
|
||||
$table->dropColumn('recipt_no');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,9 @@
|
||||
"providers": [
|
||||
"Modules\\Webstatement\\Providers\\WebstatementServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"files": [
|
||||
"app/Helpers/helpers.php"
|
||||
],
|
||||
"menu": {
|
||||
"main": [
|
||||
{
|
||||
|
||||
@@ -20,22 +20,22 @@
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-5">
|
||||
|
||||
@if ($multiBranch)
|
||||
@if (!$multiBranch)
|
||||
<div class="form-group">
|
||||
<label class="form-label required" for="branch_id">Branch/Cabang</label>
|
||||
<select class="input form-control tomselect @error('branch_id') is-invalid @enderror"
|
||||
id="branch_id" name="branch_id" required>
|
||||
<label class="form-label required" for="branch_code">Branch/Cabang</label>
|
||||
<select
|
||||
class="input form-control tomselect @error('branch_code') border-danger bg-danger-light @enderror"
|
||||
id="branch_code" name="branch_code" required>
|
||||
<option value="">Pilih Branch/Cabang</option>
|
||||
@foreach ($branches as $branchOption)
|
||||
<option value="{{ $branchOption->code }}"
|
||||
{{ old('branch_id', $statement->branch_id ?? ($branch->code ?? '')) == $branchOption->code ? 'selected' : '' }}>
|
||||
{{ old('branch_code', $statement->branch_code ?? ($branch->code ?? '')) == $branchOption->code ? 'selected' : '' }}>
|
||||
{{ $branchOption->code }} - {{ $branchOption->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('branch_id')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@error('branch_code')
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
@else
|
||||
@@ -43,27 +43,83 @@
|
||||
<label class="form-label" for="branch_display">Branch/Cabang</label>
|
||||
<input type="text" class="input form-control" id="branch_display"
|
||||
value="{{ $branch->code ?? '' }} - {{ $branch->name ?? '' }}" readonly>
|
||||
<input type="hidden" name="branch_id" value="{{ $branch->code ?? '' }}">
|
||||
<input type="hidden" name="branch_code" value="{{ $branch->code ?? '' }}">
|
||||
@error('branch_code')
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label required" for="account_number">Account Number</label>
|
||||
<input type="text" class="input form-control @error('account_number') is-invalid @enderror"
|
||||
<label class="form-label" for="stmt_sent_type">Statement Type</label>
|
||||
<select
|
||||
class="select tomselect @error('stmt_sent_type') border-danger bg-danger-light @enderror"
|
||||
id="stmt_sent_type" name="stmt_sent_type[]" multiple>
|
||||
<option value="ALL"
|
||||
{{ in_array('ALL', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
ALL
|
||||
</option>
|
||||
<option value="BY.EMAIL"
|
||||
{{ in_array('BY.EMAIL', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
BY EMAIL
|
||||
</option>
|
||||
<option value="BY.MAIL.TO.DOM.ADDR"
|
||||
{{ in_array('BY.MAIL.TO.DOM.ADDR', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
BY MAIL TO DOM ADDR
|
||||
</option>
|
||||
<option value="BY.MAIL.TO.KTP.ADDR"
|
||||
{{ in_array('BY.MAIL.TO.KTP.ADDR', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
BY MAIL TO KTP ADDR
|
||||
</option>
|
||||
<option value="NO.PRINT"
|
||||
{{ in_array('NO.PRINT', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
NO PRINT
|
||||
</option>
|
||||
<option value="PRINT"
|
||||
{{ in_array('PRINT', old('stmt_sent_type', $statement->stmt_sent_type ?? [])) ? 'selected' : '' }}>
|
||||
PRINT
|
||||
</option>
|
||||
</select>
|
||||
@error('stmt_sent_type')
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="account_number">Account Number</label>
|
||||
<input type="text"
|
||||
class="input form-control @error('account_number') border-danger bg-danger-light @enderror"
|
||||
id="account_number" name="account_number"
|
||||
value="{{ old('account_number', $statement->account_number ?? '') }}" required>
|
||||
value="{{ old('account_number', $statement->account_number ?? '') }}">
|
||||
@error('account_number')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input type="email" class="input form-control @error('email') is-invalid @enderror"
|
||||
<input type="email"
|
||||
class="input form-control @error('email') border-danger bg-danger-light @enderror"
|
||||
id="email" name="email" value="{{ old('email', $statement->email ?? '') }}"
|
||||
placeholder="Optional email for send statement">
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Tambahan field password -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">PDF Password</label>
|
||||
<input type="password"
|
||||
class="input form-control @error('password') border-danger bg-danger-light @enderror"
|
||||
id="password" name="password" value="{{ old('password', $statement->password ?? '') }}"
|
||||
placeholder="Optional password untuk proteksi PDF statement" autocomplete="new-password">
|
||||
<div class="mt-1 text-xs text-primary">
|
||||
<i class="text-sm ki-outline ki-information-5"></i>
|
||||
Jika dikosongkan password default statement akan diberlakukan
|
||||
</div>
|
||||
@error('password')
|
||||
<div class="text-sm alert text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@@ -101,317 +157,373 @@
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
|
||||
data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
|
||||
data-datatable-state-save="false" id="statement-table"
|
||||
data-api-url="{{ route('statements.datatables') }}">
|
||||
<div class="flex-wrap py-5 card-header">
|
||||
<h3 class="card-title">
|
||||
Daftar Statement Request
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 lg:gap-5">
|
||||
<div class="flex">
|
||||
<label class="input input-sm"> <i class="ki-filled ki-magnifier"> </i>
|
||||
<input placeholder="Search Statement" id="search" type="text" value="">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="scrollable-x-auto">
|
||||
<table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
|
||||
data-datatable-table="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-14">
|
||||
<input class="checkbox checkbox-sm" data-datatable-check="true" type="checkbox" />
|
||||
</th>
|
||||
<th class="min-w-[100px]" data-datatable-column="id">
|
||||
<span class="sort"> <span class="sort-label"> ID </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="branch_name">
|
||||
<span class="sort"> <span class="sort-label"> Branch </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="account_number">
|
||||
<span class="sort"> <span class="sort-label"> Account Number </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="period">
|
||||
<span class="sort"> <span class="sort-label"> Period </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="is_available">
|
||||
<span class="sort"> <span class="sort-label"> Available </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="remarks">
|
||||
<span class="sort"> <span class="sort-label"> Notes </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[180px]" data-datatable-column="created_at">
|
||||
<span class="sort"> <span class="sort-label"> Created At </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[50px] text-center" data-datatable-column="actions">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
|
||||
<div class="flex gap-2 items-center">
|
||||
Show
|
||||
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"> </select>
|
||||
per page
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<span data-datatable-info="true"> </span>
|
||||
<div class="pagination" data-datatable-pagination="true">
|
||||
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
|
||||
data-datatable-state-save="false" id="statement-table"
|
||||
data-api-url="{{ route('statements.datatables') }}">
|
||||
<div class="flex-wrap py-5 card-header">
|
||||
<h3 class="card-title">
|
||||
Daftar Statement Request
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 lg:gap-5">
|
||||
<div class="flex">
|
||||
<label class="input input-sm"> <i class="ki-filled ki-magnifier"> </i>
|
||||
<input placeholder="Search Statement" id="search" type="text"
|
||||
value="">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
<div class="card-body">
|
||||
<div class="scrollable-x-auto">
|
||||
<table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
|
||||
data-datatable-table="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-14">
|
||||
<input class="checkbox checkbox-sm" data-datatable-check="true"
|
||||
type="checkbox" />
|
||||
</th>
|
||||
<th class="min-w-[100px]" data-datatable-column="id">
|
||||
<span class="sort"> <span class="sort-label"> ID </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="branch_name">
|
||||
<span class="sort"> <span class="sort-label"> Branch </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="account_number">
|
||||
<span class="sort"> <span class="sort-label"> Account Number </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="period">
|
||||
<span class="sort"> <span class="sort-label"> Period </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="is_available">
|
||||
<span class="sort"> <span class="sort-label"> Available </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="is_generated">
|
||||
<span class="sort"> <span class="sort-label"> Generated </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[150px]" data-datatable-column="remarks">
|
||||
<span class="sort"> <span class="sort-label"> Notes </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[180px]" data-datatable-column="created_at">
|
||||
<span class="sort"> <span class="sort-label"> Created At </span>
|
||||
<span class="sort-icon"> </span> </span>
|
||||
</th>
|
||||
<th class="min-w-[50px] text-center" data-datatable-column="actions">
|
||||
Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
|
||||
<div class="flex gap-2 items-center">
|
||||
Show
|
||||
<select class="w-16 select select-sm" data-datatable-size="true"
|
||||
name="perpage"> </select>
|
||||
per page
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<span data-datatable-info="true"> </span>
|
||||
<div class="pagination" data-datatable-pagination="true">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
/**
|
||||
* Fungsi untuk menghapus data statement
|
||||
* @param {number} data - ID statement yang akan dihapus
|
||||
*/
|
||||
function deleteData(data) {
|
||||
Swal.fire({
|
||||
title: 'Are you sure?',
|
||||
text: "You won't be able to revert this!",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
confirmButtonText: 'Yes, delete it!'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
/**
|
||||
* Fungsi untuk menghapus data statement
|
||||
* @param {number} data - ID statement yang akan dihapus
|
||||
*/
|
||||
function deleteData(data) {
|
||||
Swal.fire({
|
||||
title: 'Are you sure?',
|
||||
text: "You won't be able to revert this!",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
confirmButtonText: 'Yes, delete it!'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
$.ajax(`statements/${data}`, {
|
||||
type: 'DELETE'
|
||||
}).then((response) => {
|
||||
swal.fire('Deleted!', 'Statement request has been deleted.', 'success').then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Error:', error);
|
||||
Swal.fire('Error!', 'An error occurred while deleting the record.', 'error');
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
$.ajax(`statements/${data}`, {
|
||||
type: 'DELETE'
|
||||
}).then((response) => {
|
||||
swal.fire('Deleted!', 'Statement request has been deleted.', 'success').then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Error:', error);
|
||||
Swal.fire('Error!', 'An error occurred while deleting the record.', 'error');
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfirmasi email sebelum submit form
|
||||
* Menampilkan SweetAlert jika email diisi untuk konfirmasi pengiriman
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form');
|
||||
const emailInput = document.getElementById('email');
|
||||
/**
|
||||
* Konfirmasi password dan email sebelum submit form
|
||||
* Menampilkan SweetAlert jika password atau email diisi untuk konfirmasi
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form');
|
||||
const emailInput = document.getElementById('email');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
// Log: Inisialisasi event listener untuk konfirmasi email
|
||||
console.log('Email confirmation listener initialized');
|
||||
// Log: Inisialisasi event listener untuk konfirmasi
|
||||
console.log('Form confirmation listener initialized');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
const emailValue = emailInput.value.trim();
|
||||
form.addEventListener('submit', function(e) {
|
||||
const emailValue = emailInput.value.trim();
|
||||
const passwordValue = passwordInput.value.trim();
|
||||
|
||||
// Jika email diisi, tampilkan konfirmasi
|
||||
if (emailValue) {
|
||||
e.preventDefault(); // Hentikan submit form sementara
|
||||
let confirmationNeeded = false;
|
||||
let confirmationMessage = '';
|
||||
|
||||
// Log: Email terdeteksi, menampilkan konfirmasi
|
||||
console.log('Email detected:', emailValue);
|
||||
// Jika email diisi
|
||||
if (emailValue) {
|
||||
confirmationNeeded = true;
|
||||
confirmationMessage += `• Statement akan dikirim ke email: ${emailValue}\n`;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: 'Konfirmasi Pengiriman Email',
|
||||
text: `Apakah Anda yakin ingin mengirimkan statement ke email: ${emailValue}?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
confirmButtonText: 'Ya, Kirim Email',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Log: User konfirmasi pengiriman email
|
||||
console.log('User confirmed email sending');
|
||||
// Jika password diisi
|
||||
if (passwordValue) {
|
||||
confirmationNeeded = true;
|
||||
confirmationMessage += `• PDF akan diproteksi dengan password\n`;
|
||||
}
|
||||
|
||||
// Submit form setelah konfirmasi
|
||||
form.submit();
|
||||
} else {
|
||||
// Log: User membatalkan pengiriman email
|
||||
console.log('User cancelled email sending');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Log: Tidak ada email, submit form normal
|
||||
console.log('No email provided, submitting form normally');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
// Jika ada yang perlu dikonfirmasi
|
||||
if (confirmationNeeded) {
|
||||
e.preventDefault(); // Hentikan submit form sementara
|
||||
|
||||
<script type="module">
|
||||
const element = document.querySelector('#statement-table');
|
||||
const searchInput = document.getElementById('search');
|
||||
// Log: Konfirmasi diperlukan
|
||||
console.log('Confirmation needed:', {
|
||||
email: emailValue,
|
||||
hasPassword: !!passwordValue
|
||||
});
|
||||
|
||||
const apiUrl = element.getAttribute('data-api-url');
|
||||
const dataTableOptions = {
|
||||
apiEndpoint: apiUrl,
|
||||
pageSize: 10,
|
||||
columns: {
|
||||
select: {
|
||||
render: (item, data, context) => {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.className = 'checkbox checkbox-sm';
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = data.id.toString();
|
||||
checkbox.setAttribute('data-datatable-row-check', 'true');
|
||||
return checkbox.outerHTML.trim();
|
||||
},
|
||||
},
|
||||
id: {
|
||||
title: 'ID',
|
||||
},
|
||||
branch_name: {
|
||||
title: 'Branch',
|
||||
},
|
||||
account_number: {
|
||||
title: 'Account Number',
|
||||
},
|
||||
period: {
|
||||
title: 'Period',
|
||||
render: (item, data) => {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
Swal.fire({
|
||||
title: 'Konfirmasi Request Statement',
|
||||
text: `Mohon konfirmasi pengaturan berikut:\n\n${confirmationMessage}\nApakah Anda yakin ingin melanjutkan?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
confirmButtonText: 'Ya, Lanjutkan',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true,
|
||||
preConfirm: () => {
|
||||
// Validasi password jika diisi
|
||||
if (passwordValue && passwordValue.length < 6) {
|
||||
Swal.showValidationMessage('Password minimal 6 karakter');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Log: User konfirmasi
|
||||
console.log('User confirmed form submission');
|
||||
|
||||
const formatPeriod = (period) => {
|
||||
if (!period) return '';
|
||||
const year = period.substring(0, 4);
|
||||
const month = parseInt(period.substring(4, 6));
|
||||
return `${monthNames[month - 1]} ${year}`;
|
||||
};
|
||||
// Submit form setelah konfirmasi
|
||||
form.submit();
|
||||
} else {
|
||||
// Log: User membatalkan
|
||||
console.log('User cancelled form submission');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
const fromPeriod = formatPeriod(data.period_from);
|
||||
const toPeriod = data.period_to ? ` - ${formatPeriod(data.period_to)}` : '';
|
||||
<script type="module">
|
||||
const element = document.querySelector('#statement-table');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
return fromPeriod + toPeriod;
|
||||
},
|
||||
},
|
||||
is_available: {
|
||||
title: 'Available',
|
||||
render: (item, data) => {
|
||||
let statusClass = data.is_available ? 'badge badge-light-success' :
|
||||
'badge badge-light-danger';
|
||||
let statusText = data.is_available ? 'Yes' : 'No';
|
||||
return `<span class="${statusClass}">${statusText}</span>`;
|
||||
},
|
||||
},
|
||||
remarks: {
|
||||
title: 'Notes',
|
||||
},
|
||||
created_at: {
|
||||
title: 'Created At',
|
||||
render: (item, data) => {
|
||||
return data.created_at ?? '';
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
title: 'Actions',
|
||||
render: (item, data) => {
|
||||
let buttons = `<div class="flex flex-nowrap justify-center">
|
||||
const apiUrl = element.getAttribute('data-api-url');
|
||||
const dataTableOptions = {
|
||||
apiEndpoint: apiUrl,
|
||||
pageSize: 10,
|
||||
columns: {
|
||||
select: {
|
||||
render: (item, data, context) => {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.className = 'checkbox checkbox-sm';
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = data.id.toString();
|
||||
checkbox.setAttribute('data-datatable-row-check', 'true');
|
||||
return checkbox.outerHTML.trim();
|
||||
},
|
||||
},
|
||||
id: {
|
||||
title: 'ID',
|
||||
},
|
||||
branch_name: {
|
||||
title: 'Branch',
|
||||
},
|
||||
account_number: {
|
||||
title: 'Account Number'
|
||||
},
|
||||
period: {
|
||||
title: 'Period',
|
||||
render: (item, data) => {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const formatPeriod = (period) => {
|
||||
if (!period) return '';
|
||||
const year = period.substring(0, 4);
|
||||
const month = parseInt(period.substring(4, 6));
|
||||
return `${monthNames[month - 1]} ${year}`;
|
||||
};
|
||||
|
||||
const fromPeriod = formatPeriod(data.period_from);
|
||||
const toPeriod = data.period_to ? ` - ${formatPeriod(data.period_to)}` : '';
|
||||
|
||||
return fromPeriod + toPeriod;
|
||||
},
|
||||
},
|
||||
is_available: {
|
||||
title: 'Available',
|
||||
render: (item, data) => {
|
||||
let statusClass = data.is_available ? 'badge badge-light-success' :
|
||||
|
||||
'badge badge-light-danger';
|
||||
let statusText = data.is_available ? 'Yes' : 'No';
|
||||
return `<span class="${statusClass}">${statusText}</span>`;
|
||||
},
|
||||
},
|
||||
is_generated: {
|
||||
title: 'Generated',
|
||||
render: (item, data) => {
|
||||
let statusClass = data.is_generated ? 'badge badge-light-success' :
|
||||
'badge badge-light-danger';
|
||||
let statusText = data.is_generated ? 'Yes' : 'No';
|
||||
return `<span class="${statusClass}">${statusText}</span>`;
|
||||
},
|
||||
},
|
||||
remarks: {
|
||||
title: 'Notes',
|
||||
},
|
||||
created_at: {
|
||||
title: 'Created At',
|
||||
render: (item, data) => {
|
||||
return data.created_at ?? '';
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
title: 'Actions',
|
||||
render: (item, data) => {
|
||||
let buttons = `<div class="flex flex-nowrap justify-center">
|
||||
<a class="btn btn-sm btn-icon btn-clear btn-info" href="statements/${data.id}">
|
||||
<i class="ki-outline ki-eye"></i>
|
||||
</a>`;
|
||||
|
||||
// Show download button if statement is approved and available but not downloaded
|
||||
//if (data.authorization_status === 'approved' && data.is_available && !data.is_downloaded) {
|
||||
if (data.is_available) {
|
||||
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-success" href="statements/${data.id}/download">
|
||||
// Show download button if statement is approved and available but not downloaded
|
||||
//if (data.authorization_status === 'approved' && data.is_available && !data.is_downloaded) {
|
||||
if (data.is_available) {
|
||||
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-success" href="statements/${data.id}/download">
|
||||
<i class="ki-outline ki-cloud-download"></i>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Show send email button if email is not empty and statement is available
|
||||
if (data.is_available && data.email) {
|
||||
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-primary" href="statements/${data.id}/send-email" title="Send to Email">
|
||||
// Show send email button if email is not empty and statement is available
|
||||
if (data.is_available && data.email) {
|
||||
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-primary" href="statements/${data.id}/send-email" title="Send to Email">
|
||||
<i class="ki-outline ki-paper-plane"></i>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only show delete button if status is pending
|
||||
if (data.authorization_status === 'pending') {
|
||||
buttons += `<a onclick="deleteData(${data.id})" class="delete btn btn-sm btn-icon btn-clear btn-danger">
|
||||
// Only show delete button if status is pending
|
||||
if (data.authorization_status === 'pending') {
|
||||
buttons += `<a onclick="deleteData(${data.id})" class="delete btn btn-sm btn-icon btn-clear btn-danger">
|
||||
<i class="ki-outline ki-trash"></i>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
buttons += `</div>`;
|
||||
return buttons;
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
buttons += `</div>`;
|
||||
return buttons;
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let dataTable = new KTDataTable(element, dataTableOptions);
|
||||
// Custom search functionality
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchValue = this.value.trim();
|
||||
dataTable.search(searchValue, true);
|
||||
});
|
||||
let dataTable = new KTDataTable(element, dataTableOptions);
|
||||
// Custom search functionality
|
||||
searchInput.addEventListener('input', function() {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchValue = this.value.trim();
|
||||
dataTable.search(searchValue, true);
|
||||
});
|
||||
|
||||
// Handle the "select all" checkbox
|
||||
const selectAllCheckbox = document.querySelector('input[data-datatable-check="true"]');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const rowCheckboxes = document.querySelectorAll('input[data-datatable-row-check="true"]');
|
||||
// Handle the "select all" checkbox
|
||||
const selectAllCheckbox = document.querySelector('input[data-datatable-check="true"]');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const rowCheckboxes = document.querySelectorAll(
|
||||
'input[data-datatable-row-check="true"]');
|
||||
|
||||
rowCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
rowCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Validate that end date is after start date
|
||||
const startDateInput = document.getElementById('start_date');
|
||||
const endDateInput = document.getElementById('end_date');
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Validate that end date is after start date
|
||||
const startDateInput = document.getElementById('start_date');
|
||||
const endDateInput = document.getElementById('end_date');
|
||||
|
||||
function validateDates() {
|
||||
const startDate = new Date(startDateInput.value);
|
||||
const endDate = new Date(endDateInput.value);
|
||||
function validateDates() {
|
||||
const startDate = new Date(startDateInput.value);
|
||||
const endDate = new Date(endDateInput.value);
|
||||
|
||||
if (startDate > endDate) {
|
||||
endDateInput.setCustomValidity('End date must be after start date');
|
||||
} else {
|
||||
endDateInput.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
endDateInput.setCustomValidity('End date must be after start date');
|
||||
} else {
|
||||
endDateInput.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
|
||||
startDateInput.addEventListener('change', validateDates);
|
||||
endDateInput.addEventListener('change', validateDates);
|
||||
startDateInput.addEventListener('change', validateDates);
|
||||
endDateInput.addEventListener('change', validateDates);
|
||||
|
||||
// Set max date for date inputs to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
startDateInput.setAttribute('max', today);
|
||||
endDateInput.setAttribute('max', today);
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
// Set max date for date inputs to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
startDateInput.setAttribute('max', today);
|
||||
endDateInput.setAttribute('max', today);
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
575
resources/views/statements/stmt.blade.php
Normal file
575
resources/views/statements/stmt.blade.php
Normal file
@@ -0,0 +1,575 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rekening Tabungan</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #fff;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 190mm;
|
||||
min-height: 277mm;
|
||||
margin: 10mm auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10mm;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.watermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.watermark img {
|
||||
margin: 0px 50px;
|
||||
width: 85%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* Ensure content is above watermark */
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 10px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: #0056b3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header .title h1 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header .logo {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header .logo img {
|
||||
max-height: 50px;
|
||||
margin-right: 10px
|
||||
}
|
||||
|
||||
.info-section {
|
||||
text-transform: uppercase;
|
||||
padding: 10px 0;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info-section .column {
|
||||
width: 48%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-section .column p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding-top: 15px;
|
||||
flex: 1;
|
||||
/* Allow table section to grow */
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
padding: 5px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
table th {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
table td.text-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
table td.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
table td.text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
/* Ensure footer is above watermark */
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.footer .highlight {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.left-25 {
|
||||
margin-left: 25px !important;
|
||||
}
|
||||
|
||||
.same-size {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.sponsor {
|
||||
border-top: 1.5px solid black;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
margin: 10px 0px 0px 0px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Menghilangkan padding dan margin untuk baris narrative tambahan */
|
||||
tbody tr td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* Khusus untuk baris narrative tambahan - tanpa padding/margin */
|
||||
tbody tr.narrative-line td {
|
||||
padding: 2px 5px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Column width classes */
|
||||
.col-date {
|
||||
width: 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-desc {
|
||||
width: 25%;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.col-valuta {
|
||||
width: 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-referensi {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.col-debet,
|
||||
.col-kredit {
|
||||
width: 12.5%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.col-saldo {
|
||||
width: 15%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
position: absolute;
|
||||
bottom: 5mm;
|
||||
right: 10mm;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
border: initial;
|
||||
border-radius: initial;
|
||||
width: initial;
|
||||
min-height: initial;
|
||||
box-shadow: initial;
|
||||
background: initial;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.container:last-of-type {
|
||||
page-break-after: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@php
|
||||
$saldo = $saldoAwalBulan->actual_balance ?? 0;
|
||||
$totalDebit = 0;
|
||||
$totalKredit = 0;
|
||||
$line = 1;
|
||||
$linePerPage = 26;
|
||||
@endphp
|
||||
@php
|
||||
// Hitung tanggal periode berdasarkan $period
|
||||
$periodDates = calculatePeriodDates($period);
|
||||
$startDate = $periodDates['start'];
|
||||
$endDate = $periodDates['end'];
|
||||
|
||||
// Log hasil perhitungan
|
||||
\Log::info('Period dates calculated', [
|
||||
'period' => $period,
|
||||
'start_date' => $startDate->format('d/m/Y'),
|
||||
'end_date' => $endDate->format('d/m/Y'),
|
||||
]);
|
||||
@endphp
|
||||
|
||||
@php
|
||||
// Calculate total pages based on actual line count
|
||||
$totalLines = 0;
|
||||
foreach ($stmtEntries as $entry) {
|
||||
// Split narrative into multiple lines of approximately 35 characters, breaking at word boundaries
|
||||
$narrative = $entry->description ?? '';
|
||||
$words = explode(' ', $narrative);
|
||||
|
||||
$narrativeLineCount = 0;
|
||||
$currentLine = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (strlen($currentLine . ' ' . $word) > 30) {
|
||||
$narrativeLineCount++;
|
||||
$currentLine = $word;
|
||||
} else {
|
||||
$currentLine .= ($currentLine ? ' ' : '') . $word;
|
||||
}
|
||||
}
|
||||
if ($currentLine) {
|
||||
$narrativeLineCount++;
|
||||
}
|
||||
|
||||
// Each entry takes at least one line for the main data + narrative lines + gap row
|
||||
$totalLines += $narrativeLineCount; // +1 for gap row
|
||||
}
|
||||
|
||||
// Add 1 for the "Saldo Awal Bulan" row
|
||||
$totalLines += 1;
|
||||
|
||||
// Calculate total pages ($linePerPage lines per page)
|
||||
$totalPages = ceil($totalLines / $linePerPage);
|
||||
$pageNumber = 0;
|
||||
|
||||
$footerContent =
|
||||
'
|
||||
<div class="footer">
|
||||
<p class="sponsor">Belanja puas di Electronic City! Dapatkan cashback hingga 250 ribu dan nikmati makan enak dengan cashback hingga 25 ribu. Bayar pakai QRIS AGI. S&K berlaku. Info lengkap: www.arthagraha.com</p>
|
||||
|
||||
<p class="sponsor">Waspada dalam bertransaksi QRIS! Periksa kembali identitas penjual & nominal pembayaran sebelum melanjutkan transaksi. Info terkait Bank Artha Graha Internasional, kunjungi website www.arthagraha.com</p>
|
||||
|
||||
<div class="highlight">
|
||||
<img src="' .
|
||||
public_path('assets/media/images/banner-footer.png') .
|
||||
'" alt="Logo Bank" style="width: 100%; height: auto;">
|
||||
</div>
|
||||
</div>
|
||||
';
|
||||
@endphp
|
||||
|
||||
<div class="container">
|
||||
<div class="watermark">
|
||||
<img src="{{ public_path('assets/media/images/watermark.png') }}" alt="Watermark">
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<!-- Header Section -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ public_path('assets/media/images/logo-arthagraha.png') }}" alt="Logo Bank">
|
||||
<img src="{{ public_path('assets/media/images/logo-agi.png') }}" alt="Logo Bank">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bank Information Section -->
|
||||
<div class="info-section">
|
||||
<div class="column">
|
||||
<p>{{ $branch->name }}</p>
|
||||
<p style="text-transform: capitalize">Kepada</p>
|
||||
<p>{{ $account->customer->name }}</p>
|
||||
<p>{{ $account->customer->address }}</p>
|
||||
<p>{{ $account->customer->district }}
|
||||
{{ ($account->customer->ktp_rt ?: $account->customer->home_rt) ? 'RT ' . ($account->customer->ktp_rt ?: $account->customer->home_rt) : '' }}
|
||||
{{ ($account->customer->ktp_rw ?: $account->customer->home_rw) ? 'RW ' . ($account->customer->ktp_rw ?: $account->customer->home_rw) : '' }}
|
||||
</p>
|
||||
<p>{{ trim($account->customer->city . ' ' . ($account->customer->province ? getProvinceCoreName($account->customer->province) . ' ' : '') . ($account->customer->postal_code ?? '')) }}
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-transform: capitalize;" class="column">
|
||||
<p style="padding-left:50px"><span class="same-size">Periode Statement </span>:
|
||||
{{ dateFormat($startDate) }} <span style="text-transform:lowercase !important">s/d</span>
|
||||
{{ dateFormat($endDate) }}</p>
|
||||
<p style="padding-left:50px"><span class="same-size">Nomor Rekening</span>:
|
||||
{{ $account->account_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div class="table-section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr
|
||||
style="@if ($headerTableBg) background-image: url('data:image/png;base64,{{ $headerTableBg }}'); background-repeat: no-repeat; background-size: cover; background-position: center; @else background-color: #0056b3; @endif height: 30px;">
|
||||
<th class="col-date">Tanggal</th>
|
||||
<th class="col-valuta">Tanggal<br>Valuta</th>
|
||||
<th class="text-left col-desc">Keterangan</th>
|
||||
<th class="text-left col-referensi">Referensi</th>
|
||||
<th class="col-debet">Debet</th>
|
||||
<th class="col-kredit">Kredit</th>
|
||||
<th class="col-saldo">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center"> </td>
|
||||
<td><strong>Saldo Awal Bulan</strong></td>
|
||||
<td class="text-center"> </td>
|
||||
<td class="text-right"> </td>
|
||||
<td class="text-right"> </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"> </td>
|
||||
<td class="text-right"> </td>
|
||||
<td class="text-right"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!! $footerContent !!}
|
||||
</div>
|
||||
<div class="page-number">Halaman {{ $pageNumber }} dari {{ $totalPages }}</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="watermark">
|
||||
<img src="{{ public_path('assets/media/images/watermark.png') }}" alt="Watermark">
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<!-- Header Section for continuation page -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ public_path('assets/media/images/logo-arthagraha.png') }}" alt="Logo Bank">
|
||||
<img src="{{ public_path('assets/media/images/logo-agi.png') }}" alt="Logo Bank">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bank Information Section for continuation page -->
|
||||
<div class="info-section">
|
||||
<div class="column">
|
||||
<p>{{ $branch->name }}</p>
|
||||
<p style="text-transform: capitalize">Kepada</p>
|
||||
<p>{{ $account->customer->name }}</p>
|
||||
<p>{{ $account->customer->address }}</p>
|
||||
<p>{{ $account->customer->district }}
|
||||
{{ ($account->customer->ktp_rt ?: $account->customer->home_rt) ? 'RT ' . ($account->customer->ktp_rt ?: $account->customer->home_rt) : '' }}
|
||||
{{ ($account->customer->ktp_rw ?: $account->customer->home_rw) ? 'RW ' . ($account->customer->ktp_rw ?: $account->customer->home_rw) : '' }}
|
||||
</p>
|
||||
<p>{{ trim($account->customer->city . ' ' . ($account->customer->province ? getProvinceCoreName($account->customer->province) . ' ' : '') . ($account->customer->postal_code ?? '')) }}
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-transform: capitalize;" class="column">
|
||||
<p style="padding-left:50px"><span class="same-size">Periode Statement </span>:
|
||||
{{ dateFormat($startDate) }} <span style="text-transform:lowercase !important">s/d</span>
|
||||
{{ dateFormat($endDate) }}</p>
|
||||
<p style="padding-left:50px"><span class="same-size">Nomor Rekening</span>:
|
||||
{{ $account->account_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr
|
||||
style="@if ($headerTableBg) background-image: url('data:image/png;base64,{{ $headerTableBg }}'); background-repeat: no-repeat; background-size: cover; background-position: center; @else background-color: #0056b3; @endif height: 30px;">
|
||||
<th class="col-date">Tanggal</th>
|
||||
<th class="col-valuta">Tanggal<br>Valuta</th>
|
||||
<th class="text-left col-desc">Keterangan</th>
|
||||
<th class="col-referensi">Referensi</th>
|
||||
<th class="col-debet">Debet</th>
|
||||
<th class="col-kredit">Kredit</th>
|
||||
<th class="col-saldo">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@endif
|
||||
@endforeach
|
||||
@for ($i = 0; $i < $linePerPage - $line; $i++)
|
||||
<tr>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center"></td>
|
||||
<td></td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"></td>
|
||||
</tr>
|
||||
@endfor
|
||||
<tr>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center"></td>
|
||||
<td><strong>Total Akhir</strong></td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-right"><strong>{{ number_format($totalDebit, 2, ',', '.') }}</strong></td>
|
||||
<td class="text-right"><strong>{{ number_format($totalKredit, 2, ',', '.') }}</strong>
|
||||
</td>
|
||||
<td class="text-right"><strong>{{ number_format($saldo, 2, ',', '.') }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
{!! $footerContent !!}
|
||||
</div>
|
||||
<div class="page-number">Halaman {{ $pageNumber + 1 }} dari {{ $totalPages }}</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user