Files
webstatement/app/Jobs/ExportStatementPeriodJob.php
Daeng Deni Mardaeni 593a4f0d9c feat(webstatement): tambah enkripsi password pada PDF statement
Perubahan yang dilakukan:
- Menambahkan PDFPasswordProtect::encrypt di dalam ExportStatementPeriodJob.
- Mengikuti pola implementasi yang telah digunakan pada CombinePdfJob.
- PDF statement kini otomatis diproteksi menggunakan password.
- Password diambil dari konfigurasi: webstatement.pdf_password.
- Menambahkan logging untuk memantau proses proteksi PDF.
- Menjamin pengelolaan file sementara berjalan aman dan rapi.
- Menjaga kompatibilitas ke belakang (backward compatible) dengan sistem PDF yang sudah ada.

Tujuan perubahan:
- Meningkatkan keamanan file PDF dengan proteksi password standar perusahaan.
- Memastikan proses enkripsi berjalan otomatis tanpa mengubah alur penggunaan yang ada.
- Memberikan visibilitas terhadap proses proteksi melalui log sistem.
2025-07-10 14:13:16 +07:00

657 lines
23 KiB
PHP

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