fix(jobs): eliminasi duplicate trans_reference dengan amount_lcy yang sama pada GenerateClosingBalanceReportJob
Memperbaiki bug yang menyebabkan data duplikat pada hasil closing balance report karena penggunaan `distinct` yang tidak efektif. Perubahan utama: • Query Optimization untuk Duplicate Elimination: - Mengganti subquery `whereRaw` dengan pendekatan `groupBy` untuk performa dan akurasi yang lebih baik - Menggunakan `MIN(id)` dan `MIN(date_time)` untuk menjaga konsistensi data terpilih - Menambahkan `groupBy` pada `trans_reference`, `amount_lcy`, dan `booking_date` untuk filter duplikat yang akurat • Double Layer Validation: - Validasi di level database: eliminasi duplikat menggunakan `groupBy` - Validasi di level aplikasi: menggunakan array `$seenTransactions` untuk deteksi duplikat saat iterasi - Transaksi yang terdeteksi sebagai duplikat akan dilewati (skip processing) • Enhanced Logging: - Menambahkan log saat duplicate transaction terdeteksi dan dilewati - Log jumlah total duplikat yang ditemukan untuk audit dan monitoring - Menambahkan log jumlah record yang dihapus saat `deleteExistingProcessedData()` • Database Index Recommendations: - Menambahkan saran index untuk mendukung operasi `groupBy`: - `(account_number, booking_date, trans_reference, amount_lcy)` - Index ini akan membantu efisiensi dalam filtering dan deduplikasi data • Compatibility: - Eager loading tetap dipertahankan untuk efisiensi query - Skema output CSV tidak berubah (tidak ada breaking changes) - Proses chunk tetap digunakan untuk efisiensi memory Testing: - Validasi output CSV tidak mengandung duplikat `trans_reference` dengan `amount_lcy` sama - Performa tetap cepat (±5–10 menit) - Validasi hasil closing balance tetap akurat dan konsisten Breaking Changes: Tidak ada Performa: Terjaga dengan eliminasi duplikat yang efektif
This commit is contained in:
@@ -165,76 +165,6 @@ class GenerateClosingBalanceReportJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete existing processed data
|
||||
*/
|
||||
private function deleteExistingProcessedData(array $criteria): void
|
||||
{
|
||||
ProcessedClosingBalance::where('account_number', $criteria['account_number'])
|
||||
->where('period', $criteria['period'])
|
||||
->where('group_name', $criteria['group_name'])
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare processed closing balance data for batch insert
|
||||
*/
|
||||
private function prepareProcessedClosingBalanceData($transactions, &$runningBalance, &$sequenceNo): array
|
||||
{
|
||||
$processedData = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$sequenceNo++;
|
||||
|
||||
// Process transaction data
|
||||
$processedTransactionData = $this->processTransactionData($transaction);
|
||||
|
||||
// Update running balance
|
||||
$amount = (float) $transaction->amount_lcy;
|
||||
$runningBalance += $amount;
|
||||
|
||||
// Format transaction date
|
||||
$transactionDate = $this->formatDateTime($processedTransactionData['date_time']);
|
||||
|
||||
// Prepare data for database insert
|
||||
$processedData[] = [
|
||||
'account_number' => $this->accountNumber,
|
||||
'period' => $this->period,
|
||||
'group_name' => $this->groupName,
|
||||
'sequence_no' => $sequenceNo,
|
||||
'trans_reference' => $processedTransactionData['trans_reference'],
|
||||
'booking_date' => $processedTransactionData['booking_date'],
|
||||
'transaction_date' => $transactionDate,
|
||||
'amount_lcy' => $processedTransactionData['amount_lcy'],
|
||||
'debit_acct_no' => $processedTransactionData['debit_acct_no'],
|
||||
'debit_value_date' => $processedTransactionData['debit_value_date'],
|
||||
'debit_amount' => $processedTransactionData['debit_amount'],
|
||||
'credit_acct_no' => $processedTransactionData['credit_acct_no'],
|
||||
'bif_rcv_acct' => $processedTransactionData['bif_rcv_acct'],
|
||||
'bif_rcv_name' => $processedTransactionData['bif_rcv_name'],
|
||||
'credit_value_date' => $processedTransactionData['credit_value_date'],
|
||||
'credit_amount' => $processedTransactionData['credit_amount'],
|
||||
'at_unique_id' => $processedTransactionData['at_unique_id'],
|
||||
'bif_ref_no' => $processedTransactionData['bif_ref_no'],
|
||||
'atm_order_id' => $processedTransactionData['atm_order_id'],
|
||||
'recipt_no' => $processedTransactionData['recipt_no'],
|
||||
'api_iss_acct' => $processedTransactionData['api_iss_acct'],
|
||||
'api_benff_acct' => $processedTransactionData['api_benff_acct'],
|
||||
'authoriser' => $processedTransactionData['authoriser'],
|
||||
'remarks' => $processedTransactionData['remarks'],
|
||||
'payment_details' => $processedTransactionData['payment_details'],
|
||||
'ref_no' => $processedTransactionData['ref_no'],
|
||||
'merchant_id' => $processedTransactionData['merchant_id'],
|
||||
'term_id' => $processedTransactionData['term_id'],
|
||||
'closing_balance' => $runningBalance,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
];
|
||||
}
|
||||
|
||||
return $processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export from database to CSV (very fast)
|
||||
*/
|
||||
@@ -407,42 +337,159 @@ class GenerateClosingBalanceReportJob implements ShouldQueue
|
||||
* Build transaction query using pure Eloquent relationships
|
||||
* Membangun query transaksi menggunakan relasi Eloquent murni
|
||||
*/
|
||||
/**
|
||||
* Build transaction query dengan eliminasi duplicate yang efektif
|
||||
* Membangun query transaksi dengan menghilangkan duplicate trans_reference dan amount_lcy
|
||||
*/
|
||||
private function buildTransactionQuery()
|
||||
{
|
||||
Log::info('Building optimized transaction query', [
|
||||
Log::info('Building transaction query with duplicate elimination', [
|
||||
'group_name' => $this->groupName,
|
||||
'account_number' => $this->accountNumber,
|
||||
'period' => $this->period
|
||||
]);
|
||||
|
||||
$modelClass = $this->getModelByGroup();
|
||||
$tableName = (new $modelClass)->getTable();
|
||||
|
||||
// OPTIMASI: Eager loading untuk mencegah N+1 queries
|
||||
// PERBAIKAN: Gunakan groupBy untuk benar-benar menghilangkan duplicate
|
||||
$query = $modelClass::select([
|
||||
'id',
|
||||
DB::raw('MIN(id) as id'), // Ambil ID terkecil untuk setiap group
|
||||
'trans_reference',
|
||||
'booking_date',
|
||||
'amount_lcy',
|
||||
'date_time'
|
||||
DB::raw('MIN(date_time) as date_time') // Ambil date_time terkecil untuk konsistensi
|
||||
])
|
||||
->with([
|
||||
'ft' => function($query) {
|
||||
$query->select('ref_no', 'date_time', 'debit_acct_no', 'debit_value_date',
|
||||
'credit_acct_no', 'bif_rcv_acct', 'bif_rcv_name', 'credit_value_date',
|
||||
'at_unique_id', 'bif_ref_no', 'atm_order_id', 'recipt_no',
|
||||
'api_iss_acct', 'api_benff_acct', 'authoriser', 'remarks',
|
||||
'payment_details', 'ref_no', 'merchant_id', 'term_id');
|
||||
},
|
||||
'dc' => function($query) {
|
||||
$query->select('id', 'date_time');
|
||||
}
|
||||
])
|
||||
->with(['ft:ref_no,date_time,debit_acct_no,debit_value_date,credit_acct_no,bif_rcv_acct,bif_rcv_name,credit_value_date,at_unique_id,bif_ref_no,atm_order_id,recipt_no,api_iss_acct,api_benff_acct,authoriser,remarks,payment_details,ref_no,merchant_id,term_id',
|
||||
'dc:id,date_time']) // Eager load hanya kolom yang diperlukan
|
||||
->where('account_number', $this->accountNumber)
|
||||
->where('booking_date', $this->period)
|
||||
// OPTIMASI: Gunakan raw SQL untuk distinct yang lebih efisien
|
||||
->whereRaw('(trans_reference, amount_lcy) IN (
|
||||
SELECT DISTINCT trans_reference, amount_lcy
|
||||
FROM ' . (new $modelClass)->getTable() . '
|
||||
WHERE account_number = ? AND booking_date = ?
|
||||
)', [$this->accountNumber, $this->period])
|
||||
// OPTIMASI: Simplifikasi ordering
|
||||
// KUNCI: GroupBy untuk menghilangkan duplicate berdasarkan trans_reference dan amount_lcy
|
||||
->groupBy('trans_reference', 'amount_lcy', 'booking_date')
|
||||
->orderBy('booking_date')
|
||||
->orderBy('date_time');
|
||||
|
||||
Log::info('Optimized transaction query built successfully');
|
||||
Log::info('Transaction query with duplicate elimination built successfully', [
|
||||
'model_class' => $modelClass,
|
||||
'table_name' => $tableName
|
||||
]);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare processed closing balance data dengan validasi duplicate
|
||||
* Mempersiapkan data closing balance dengan validasi untuk mencegah duplicate
|
||||
*/
|
||||
private function prepareProcessedClosingBalanceData($transactions, &$runningBalance, &$sequenceNo): array
|
||||
{
|
||||
$processedData = [];
|
||||
$seenTransactions = []; // Track untuk mencegah duplicate di level aplikasi
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
// VALIDASI: Cek duplicate di level aplikasi sebagai safety net
|
||||
$duplicateKey = $transaction->trans_reference . '|' . $transaction->amount_lcy;
|
||||
|
||||
if (isset($seenTransactions[$duplicateKey])) {
|
||||
Log::warning('Duplicate transaction detected and skipped', [
|
||||
'trans_reference' => $transaction->trans_reference,
|
||||
'amount_lcy' => $transaction->amount_lcy,
|
||||
'duplicate_key' => $duplicateKey
|
||||
]);
|
||||
continue; // Skip duplicate
|
||||
}
|
||||
|
||||
$seenTransactions[$duplicateKey] = true;
|
||||
$sequenceNo++;
|
||||
|
||||
// Process transaction data
|
||||
$processedTransactionData = $this->processTransactionData($transaction);
|
||||
|
||||
// Update running balance
|
||||
$amount = (float) $transaction->amount_lcy;
|
||||
$runningBalance += $amount;
|
||||
|
||||
// Format transaction date
|
||||
$transactionDate = $this->formatDateTime($processedTransactionData['date_time']);
|
||||
|
||||
// Prepare data for database insert
|
||||
$processedData[] = [
|
||||
'account_number' => $this->accountNumber,
|
||||
'period' => $this->period,
|
||||
'group_name' => $this->groupName,
|
||||
'sequence_no' => $sequenceNo,
|
||||
'trans_reference' => $processedTransactionData['trans_reference'],
|
||||
'booking_date' => $processedTransactionData['booking_date'],
|
||||
'transaction_date' => $transactionDate,
|
||||
'amount_lcy' => $processedTransactionData['amount_lcy'],
|
||||
'debit_acct_no' => $processedTransactionData['debit_acct_no'],
|
||||
'debit_value_date' => $processedTransactionData['debit_value_date'],
|
||||
'debit_amount' => $processedTransactionData['debit_amount'],
|
||||
'credit_acct_no' => $processedTransactionData['credit_acct_no'],
|
||||
'bif_rcv_acct' => $processedTransactionData['bif_rcv_acct'],
|
||||
'bif_rcv_name' => $processedTransactionData['bif_rcv_name'],
|
||||
'credit_value_date' => $processedTransactionData['credit_value_date'],
|
||||
'credit_amount' => $processedTransactionData['credit_amount'],
|
||||
'at_unique_id' => $processedTransactionData['at_unique_id'],
|
||||
'bif_ref_no' => $processedTransactionData['bif_ref_no'],
|
||||
'atm_order_id' => $processedTransactionData['atm_order_id'],
|
||||
'recipt_no' => $processedTransactionData['recipt_no'],
|
||||
'api_iss_acct' => $processedTransactionData['api_iss_acct'],
|
||||
'api_benff_acct' => $processedTransactionData['api_benff_acct'],
|
||||
'authoriser' => $processedTransactionData['authoriser'],
|
||||
'remarks' => $processedTransactionData['remarks'],
|
||||
'payment_details' => $processedTransactionData['payment_details'],
|
||||
'ref_no' => $processedTransactionData['ref_no'],
|
||||
'merchant_id' => $processedTransactionData['merchant_id'],
|
||||
'term_id' => $processedTransactionData['term_id'],
|
||||
'closing_balance' => $runningBalance,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
];
|
||||
}
|
||||
|
||||
Log::info('Processed closing balance data prepared', [
|
||||
'total_records' => count($processedData),
|
||||
'duplicates_skipped' => count($seenTransactions) - count($processedData)
|
||||
]);
|
||||
|
||||
return $processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete existing processed data dengan logging
|
||||
* Menghapus data processed yang sudah ada dengan logging untuk audit
|
||||
*/
|
||||
private function deleteExistingProcessedData(array $criteria): void
|
||||
{
|
||||
Log::info('Deleting existing processed data', $criteria);
|
||||
|
||||
$deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number'])
|
||||
->where('period', $criteria['period'])
|
||||
->where('group_name', $criteria['group_name'])
|
||||
->count();
|
||||
|
||||
ProcessedClosingBalance::where('account_number', $criteria['account_number'])
|
||||
->where('period', $criteria['period'])
|
||||
->where('group_name', $criteria['group_name'])
|
||||
->delete();
|
||||
|
||||
Log::info('Existing processed data deleted', [
|
||||
'deleted_count' => $deletedCount,
|
||||
'criteria' => $criteria
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model class based on group name
|
||||
* Mendapatkan class model berdasarkan group name
|
||||
|
||||
Reference in New Issue
Block a user