daily_collection_stats, weekly_route_performance, monthly_store_sales aggregation tables. Aggregator service is idempotent — wipes the slice and re-inserts. php artisan reports:aggregate (default: yesterday) for the nightly cron. Admin endpoints: daily-collection / trip-performance / store-sales chart series + totals, compliance.csv stream of dumpsite releases (DENR-style), POST rebuild for on-demand aggregation. Payments, notifications, and live tracking sub-modules of Module 13 are deferred per scope. 164 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
3.4 KiB
PHP
96 lines
3.4 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\V1\Report;
|
|
|
|
use App\Models\CollectionLog;
|
|
use App\Models\DropOffPoint;
|
|
use App\Models\Household;
|
|
use App\Models\QrCode;
|
|
use App\Models\QrCodeBatch;
|
|
use App\Models\User;
|
|
use App\Services\Qr\BatchGenerator;
|
|
use App\Services\Report\Aggregator;
|
|
use Database\Seeders\RoleSeeder;
|
|
use Database\Seeders\SampleDropOffPointsSeeder;
|
|
use Database\Seeders\SamplePsgcSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use MatanYadaev\EloquentSpatial\Objects\Point;
|
|
use Tests\TestCase;
|
|
|
|
class ReportTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class]);
|
|
$this->admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']);
|
|
}
|
|
|
|
public function test_aggregator_rolls_up_collection_logs_into_daily_stats(): void
|
|
{
|
|
$dop = DropOffPoint::first();
|
|
$household = Household::factory()->create(['barangay_id' => $dop->barangay_id]);
|
|
$batch = app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_FREE);
|
|
$batch->codes->each(function ($c) use ($household, $dop) {
|
|
$c->forceFill([
|
|
'assigned_to_household_id' => $household->id,
|
|
'status' => 'used',
|
|
'used_at' => now(),
|
|
'used_at_drop_off_id' => $dop->id,
|
|
])->save();
|
|
CollectionLog::create([
|
|
'qr_code_id' => $c->id,
|
|
'household_id' => $household->id,
|
|
'drop_off_point_id' => $dop->id,
|
|
'scanned_at' => now(),
|
|
'coordinates_at_scan' => new Point(14.6539, 121.0685, 4326),
|
|
'weight_kg' => 5,
|
|
'verification_status' => 'valid',
|
|
]);
|
|
});
|
|
|
|
$count = app(Aggregator::class)->rebuildDailyCollectionStats(now());
|
|
$this->assertGreaterThanOrEqual(1, $count);
|
|
|
|
Sanctum::actingAs($this->admin);
|
|
$response = $this->getJson('/api/v1/admin/reports/daily-collection?from='.now()->toDateString().'&to='.now()->toDateString());
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.totals.total_scans', 3)
|
|
->assertJsonPath('data.totals.total_weight_kg', 15);
|
|
}
|
|
|
|
public function test_admin_can_rebuild_aggregations(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
|
|
$response = $this->postJson('/api/v1/admin/reports/rebuild', ['date' => now()->toDateString()]);
|
|
|
|
$response->assertOk()
|
|
->assertJsonStructure(['data' => ['daily_buckets', 'weekly_buckets', 'monthly_buckets']]);
|
|
}
|
|
|
|
public function test_compliance_csv_streams_dumpsite_releases(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
|
|
$response = $this->get('/api/v1/admin/reports/compliance.csv?from=2026-01-01&to=2026-12-31');
|
|
$response->assertOk();
|
|
$this->assertStringContainsString('text/csv', $response->headers->get('content-type'));
|
|
$this->assertStringContainsString('released_at,trip_number,dumpsite,permit_number', $response->streamedContent());
|
|
}
|
|
|
|
public function test_resident_blocked_from_reports(): void
|
|
{
|
|
$resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']);
|
|
Sanctum::actingAs($resident);
|
|
|
|
$this->getJson('/api/v1/admin/reports/daily-collection')->assertStatus(403);
|
|
}
|
|
}
|