Merge branch 'test/ar-summary-report-optimization' into v5-develop
This commit is contained in:
commit
fc3ce17d15
|
|
@ -0,0 +1,594 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Utils\Traits\MakesHash;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test AR Summary Report optimization strategy.
|
||||||
|
*
|
||||||
|
* This test compares the current N+1 query approach (6 queries per client)
|
||||||
|
* against an optimized single-query approach using CASE statements.
|
||||||
|
*/
|
||||||
|
class ARSummaryReportOptimizationTest extends TestCase
|
||||||
|
{
|
||||||
|
use DatabaseTransactions, MakesHash;
|
||||||
|
|
||||||
|
protected Company $company;
|
||||||
|
protected User $user;
|
||||||
|
protected array $testClients = [];
|
||||||
|
protected array $testInvoices = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$account = Account::factory()->create();
|
||||||
|
|
||||||
|
$this->company = Company::factory()->create([
|
||||||
|
'account_id' => $account->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->user = User::factory()->create([
|
||||||
|
'account_id' => $account->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create test data: clients with invoices in various aging buckets.
|
||||||
|
*/
|
||||||
|
private function createTestData(int $clientCount = 10): void
|
||||||
|
{
|
||||||
|
$now = now()->startOfDay();
|
||||||
|
|
||||||
|
for ($i = 0; $i < $clientCount; $i++) {
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'name' => "Test Client {$i}",
|
||||||
|
'number' => "CLI-{$i}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->testClients[] = $client;
|
||||||
|
|
||||||
|
// Create invoices in different aging buckets
|
||||||
|
$invoiceScenarios = [
|
||||||
|
// Current (due in future or no due date)
|
||||||
|
['balance' => 100, 'due_date' => $now->copy()->addDays(10), 'status' => Invoice::STATUS_SENT],
|
||||||
|
['balance' => 50, 'due_date' => null, 'status' => Invoice::STATUS_SENT],
|
||||||
|
|
||||||
|
// 0-30 days overdue
|
||||||
|
['balance' => 200, 'due_date' => $now->copy()->subDays(15), 'status' => Invoice::STATUS_SENT],
|
||||||
|
['balance' => 150, 'due_date' => $now->copy()->subDays(25), 'status' => Invoice::STATUS_PARTIAL],
|
||||||
|
|
||||||
|
// 31-60 days overdue
|
||||||
|
['balance' => 300, 'due_date' => $now->copy()->subDays(45), 'status' => Invoice::STATUS_SENT],
|
||||||
|
|
||||||
|
// 61-90 days overdue
|
||||||
|
['balance' => 400, 'due_date' => $now->copy()->subDays(75), 'status' => Invoice::STATUS_SENT],
|
||||||
|
|
||||||
|
// 91-120 days overdue
|
||||||
|
['balance' => 500, 'due_date' => $now->copy()->subDays(105), 'status' => Invoice::STATUS_SENT],
|
||||||
|
|
||||||
|
// 120+ days overdue
|
||||||
|
['balance' => 600, 'due_date' => $now->copy()->subDays(150), 'status' => Invoice::STATUS_SENT],
|
||||||
|
['balance' => 700, 'due_date' => $now->copy()->subDays(365), 'status' => Invoice::STATUS_PARTIAL],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($invoiceScenarios as $scenario) {
|
||||||
|
$invoice = Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => $scenario['status'],
|
||||||
|
'balance' => $scenario['balance'],
|
||||||
|
'amount' => $scenario['balance'],
|
||||||
|
'due_date' => $scenario['due_date'],
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->testInvoices[] = $invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current implementation: N+1 query approach (6 queries per client).
|
||||||
|
*/
|
||||||
|
private function getCurrentImplementationResults(Client $client): array
|
||||||
|
{
|
||||||
|
$now = now()->startOfDay();
|
||||||
|
|
||||||
|
// Current invoices
|
||||||
|
$current = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where(function ($query) use ($now) {
|
||||||
|
$query->where('due_date', '>', $now)
|
||||||
|
->orWhereNull('due_date');
|
||||||
|
})
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
// 0-30 days
|
||||||
|
$age_30 = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereBetween('due_date', [$now->copy()->subDays(30), $now])
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
// 31-60 days
|
||||||
|
$age_60 = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereBetween('due_date', [$now->copy()->subDays(60), $now->copy()->subDays(31)])
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
// 61-90 days
|
||||||
|
$age_90 = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereBetween('due_date', [$now->copy()->subDays(90), $now->copy()->subDays(61)])
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
// 91-120 days
|
||||||
|
$age_120 = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereBetween('due_date', [$now->copy()->subDays(120), $now->copy()->subDays(91)])
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
// 120+ days
|
||||||
|
$age_120_plus = Invoice::withTrashed()
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->where('company_id', $client->company_id)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereBetween('due_date', [$now->copy()->subYears(20), $now->copy()->subDays(121)])
|
||||||
|
->sum('balance');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'current' => $current,
|
||||||
|
'age_30' => $age_30,
|
||||||
|
'age_60' => $age_60,
|
||||||
|
'age_90' => $age_90,
|
||||||
|
'age_120' => $age_120,
|
||||||
|
'age_120_plus' => $age_120_plus,
|
||||||
|
'total' => $current + $age_30 + $age_60 + $age_90 + $age_120 + $age_120_plus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimized implementation: Single query with CASE statements.
|
||||||
|
*/
|
||||||
|
private function getOptimizedImplementationResults(array $clientIds)
|
||||||
|
{
|
||||||
|
$now = now()->startOfDay();
|
||||||
|
$nowStr = $now->toDateString();
|
||||||
|
$date_30 = $now->copy()->subDays(30)->toDateString();
|
||||||
|
$date_31 = $now->copy()->subDays(31)->toDateString();
|
||||||
|
$date_60 = $now->copy()->subDays(60)->toDateString();
|
||||||
|
$date_61 = $now->copy()->subDays(61)->toDateString();
|
||||||
|
$date_90 = $now->copy()->subDays(90)->toDateString();
|
||||||
|
$date_91 = $now->copy()->subDays(91)->toDateString();
|
||||||
|
$date_120 = $now->copy()->subDays(120)->toDateString();
|
||||||
|
$date_121 = $now->copy()->subDays(121)->toDateString();
|
||||||
|
$pastDate = $now->copy()->subYears(20)->toDateString();
|
||||||
|
|
||||||
|
$results = DB::table('invoices')
|
||||||
|
->selectRaw('
|
||||||
|
client_id,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN (due_date > ? OR due_date IS NULL)
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as current,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN due_date BETWEEN ? AND ?
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as age_30,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN due_date BETWEEN ? AND ?
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as age_60,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN due_date BETWEEN ? AND ?
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as age_90,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN due_date BETWEEN ? AND ?
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as age_120,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN due_date BETWEEN ? AND ?
|
||||||
|
THEN balance
|
||||||
|
ELSE 0
|
||||||
|
END) as age_120_plus,
|
||||||
|
SUM(balance) as total
|
||||||
|
', [
|
||||||
|
$nowStr, // current > now
|
||||||
|
$date_30, $nowStr, // 0-30 days
|
||||||
|
$date_60, $date_31, // 31-60 days
|
||||||
|
$date_90, $date_61, // 61-90 days
|
||||||
|
$date_120, $date_91, // 91-120 days
|
||||||
|
$pastDate, $date_121, // 120+ days
|
||||||
|
])
|
||||||
|
->where('company_id', $this->company->id)
|
||||||
|
->where('is_deleted', 0)
|
||||||
|
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||||
|
->where('balance', '>', 0)
|
||||||
|
->whereIn('client_id', $clientIds)
|
||||||
|
->groupBy('client_id')
|
||||||
|
->get()
|
||||||
|
->keyBy('client_id');
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test data quality: verify optimized query produces same results as current.
|
||||||
|
*/
|
||||||
|
public function testDataQualityOptimizedMatchesCurrent()
|
||||||
|
{
|
||||||
|
$this->createTestData(5);
|
||||||
|
|
||||||
|
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults($clientIds);
|
||||||
|
|
||||||
|
foreach ($this->testClients as $client) {
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
|
||||||
|
$this->assertNotNull($optimizedResult, "Client {$client->id} not found in optimized results");
|
||||||
|
|
||||||
|
// Compare each aging bucket
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['current'],
|
||||||
|
$optimizedResult->current,
|
||||||
|
"Current balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['age_30'],
|
||||||
|
$optimizedResult->age_30,
|
||||||
|
"0-30 days balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['age_60'],
|
||||||
|
$optimizedResult->age_60,
|
||||||
|
"31-60 days balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['age_90'],
|
||||||
|
$optimizedResult->age_90,
|
||||||
|
"61-90 days balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['age_120'],
|
||||||
|
$optimizedResult->age_120,
|
||||||
|
"91-120 days balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['age_120_plus'],
|
||||||
|
$optimizedResult->age_120_plus,
|
||||||
|
"120+ days balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults['total'],
|
||||||
|
$optimizedResult->total,
|
||||||
|
"Total balance mismatch for client {$client->name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test edge case: client with no invoices.
|
||||||
|
*/
|
||||||
|
public function testClientWithNoInvoices()
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||||
|
|
||||||
|
// Current implementation returns 0 for all buckets
|
||||||
|
$this->assertEquals(0, $currentResults['current']);
|
||||||
|
$this->assertEquals(0, $currentResults['total']);
|
||||||
|
|
||||||
|
// Optimized should either not return the client or return zeros
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
if ($optimizedResult) {
|
||||||
|
$this->assertEquals(0, $optimizedResult->total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test edge case: invoices with status other than SENT/PARTIAL should be excluded.
|
||||||
|
*/
|
||||||
|
public function testExcludesNonSentPartialInvoices()
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create invoices with various statuses
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => 100,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_DRAFT, // Should be excluded
|
||||||
|
'balance' => 200,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_PAID, // Should be excluded
|
||||||
|
'balance' => 0,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
|
||||||
|
// Should only count the SENT invoice
|
||||||
|
$this->assertEquals(100, $currentResults['age_30']);
|
||||||
|
$this->assertEquals(100, $optimizedResult->age_30);
|
||||||
|
$this->assertEquals(100, $currentResults['total']);
|
||||||
|
$this->assertEquals(100, $optimizedResult->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test edge case: deleted invoices should be excluded.
|
||||||
|
*/
|
||||||
|
public function testExcludesDeletedInvoices()
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => 100,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => 200,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => true, // Should be excluded
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
|
||||||
|
$this->assertEquals(100, $currentResults['age_30']);
|
||||||
|
$this->assertEquals(100, $optimizedResult->age_30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test edge case: invoices with zero balance should be excluded.
|
||||||
|
*/
|
||||||
|
public function testExcludesZeroBalanceInvoices()
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => 100,
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => 0, // Should be excluded
|
||||||
|
'due_date' => now()->subDays(10),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
|
||||||
|
$this->assertEquals(100, $currentResults['age_30']);
|
||||||
|
$this->assertEquals(100, $optimizedResult->age_30);
|
||||||
|
$this->assertEquals(100, $currentResults['total']);
|
||||||
|
$this->assertEquals(100, $optimizedResult->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance test: compare query count between implementations.
|
||||||
|
*/
|
||||||
|
public function testPerformanceQueryCount()
|
||||||
|
{
|
||||||
|
$this->createTestData(10);
|
||||||
|
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||||
|
|
||||||
|
// Count queries for current implementation
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
foreach ($this->testClients as $client) {
|
||||||
|
$this->getCurrentImplementationResults($client);
|
||||||
|
}
|
||||||
|
$currentQueries = DB::getQueryLog();
|
||||||
|
// Only count invoice queries (not time zone or other queries)
|
||||||
|
$currentQueryCount = count(array_filter($currentQueries, function($query) {
|
||||||
|
return strpos($query['query'], 'from `invoices`') !== false;
|
||||||
|
}));
|
||||||
|
DB::disableQueryLog();
|
||||||
|
|
||||||
|
// Count queries for optimized implementation
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
$this->getOptimizedImplementationResults($clientIds);
|
||||||
|
$optimizedQueries = DB::getQueryLog();
|
||||||
|
// Only count invoice queries
|
||||||
|
$optimizedQueryCount = count(array_filter($optimizedQueries, function($query) {
|
||||||
|
return strpos($query['query'], 'from `invoices`') !== false;
|
||||||
|
}));
|
||||||
|
DB::disableQueryLog();
|
||||||
|
|
||||||
|
// Current should be 6 queries per client (6 * 10 = 60)
|
||||||
|
$this->assertEquals(60, $currentQueryCount, 'Current implementation should execute 6 queries per client');
|
||||||
|
|
||||||
|
// Optimized should be 1 query total
|
||||||
|
$this->assertEquals(1, $optimizedQueryCount, 'Optimized implementation should execute 1 query total');
|
||||||
|
|
||||||
|
$improvement = $currentQueryCount / $optimizedQueryCount;
|
||||||
|
$this->assertGreaterThan(50, $improvement, 'Optimized should be at least 50x better');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance test: measure execution time difference.
|
||||||
|
*/
|
||||||
|
public function testPerformanceExecutionTime()
|
||||||
|
{
|
||||||
|
$this->createTestData(50);
|
||||||
|
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||||
|
|
||||||
|
// Measure current implementation time
|
||||||
|
$currentStart = microtime(true);
|
||||||
|
foreach ($this->testClients as $client) {
|
||||||
|
$this->getCurrentImplementationResults($client);
|
||||||
|
}
|
||||||
|
$currentTime = microtime(true) - $currentStart;
|
||||||
|
|
||||||
|
// Measure optimized implementation time
|
||||||
|
$optimizedStart = microtime(true);
|
||||||
|
$this->getOptimizedImplementationResults($clientIds);
|
||||||
|
$optimizedTime = microtime(true) - $optimizedStart;
|
||||||
|
|
||||||
|
// Optimized should be significantly faster
|
||||||
|
$this->assertLessThan($currentTime, $optimizedTime, 'Optimized should be faster');
|
||||||
|
|
||||||
|
$speedup = $currentTime / $optimizedTime;
|
||||||
|
dump([
|
||||||
|
'clients' => 50,
|
||||||
|
'current_time' => round($currentTime, 4) . 's',
|
||||||
|
'optimized_time' => round($optimizedTime, 4) . 's',
|
||||||
|
'speedup' => round($speedup, 2) . 'x',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test aging bucket boundaries are correct.
|
||||||
|
*/
|
||||||
|
public function testAgingBucketBoundaries()
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$now = now()->startOfDay();
|
||||||
|
|
||||||
|
// Create invoices exactly on bucket boundaries
|
||||||
|
$boundaries = [
|
||||||
|
['days' => 0, 'balance' => 100, 'expected_bucket' => 'age_30'], // Today = 0-30
|
||||||
|
['days' => 30, 'balance' => 200, 'expected_bucket' => 'age_30'], // Exactly 30 days
|
||||||
|
['days' => 31, 'balance' => 300, 'expected_bucket' => 'age_60'], // Exactly 31 days
|
||||||
|
['days' => 60, 'balance' => 400, 'expected_bucket' => 'age_60'], // Exactly 60 days
|
||||||
|
['days' => 61, 'balance' => 500, 'expected_bucket' => 'age_90'], // Exactly 61 days
|
||||||
|
['days' => 90, 'balance' => 600, 'expected_bucket' => 'age_90'], // Exactly 90 days
|
||||||
|
['days' => 91, 'balance' => 700, 'expected_bucket' => 'age_120'], // Exactly 91 days
|
||||||
|
['days' => 120, 'balance' => 800, 'expected_bucket' => 'age_120'], // Exactly 120 days
|
||||||
|
['days' => 121, 'balance' => 900, 'expected_bucket' => 'age_120_plus'], // Exactly 121 days
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($boundaries as $boundary) {
|
||||||
|
Invoice::factory()->create([
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status_id' => Invoice::STATUS_SENT,
|
||||||
|
'balance' => $boundary['balance'],
|
||||||
|
'due_date' => $now->copy()->subDays($boundary['days']),
|
||||||
|
'is_deleted' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentResults = $this->getCurrentImplementationResults($client);
|
||||||
|
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||||
|
$optimizedResult = $optimizedResults->get($client->id);
|
||||||
|
|
||||||
|
// Both implementations should produce same bucket totals
|
||||||
|
foreach (['age_30', 'age_60', 'age_90', 'age_120', 'age_120_plus'] as $bucket) {
|
||||||
|
$this->assertEquals(
|
||||||
|
$currentResults[$bucket],
|
||||||
|
$optimizedResult->$bucket,
|
||||||
|
"Bucket boundary mismatch for {$bucket}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue