diff --git a/CLAUDE.md b/CLAUDE.md index fa644bc..6ddb35b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,8 +223,52 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in MySQL `ST_Distance_Sphere` between consecutive stop coordinates + dumpsite, plus dwell time at stops, +20 min at dumpsite, divided by `config('routes.avg_speed_kmh', 25.0)` -- All 139 feature tests passing -- [ ] Module 9+: see `../docs/development-roadmap.md` +- [x] Module 9: Teams + Trucks — complete + - `trucks` (with last_known_coordinates POINT 4326), `collection_teams`, + `team_members`. Promoted `routes.default_team_id` to a real FK. + - Admin CRUD for trucks and teams; `TeamConflictDetector` flags + double-assignment of driver/scanner/truck/active helper across + teams; `override_conflicts: true` bypasses for emergency rotations. +- [x] Module 10: Trips + Timeline — complete + - `trips`, `trip_stops`, `trip_timeline_events`. Trip number auto + `TRIP-YYYYMMDD-NNN`. Promoted `dumpsite_releases.trip_id` to FK. + - Admin: schedule (clones route stops onto trip_stops), show, cancel. + - Driver: `start`, `arrive`/`depart`/`skip` per stop, `report-incident`, + `arrive-dumpsite`, `release-load` (creates DumpsiteRelease + tallies + trip total_load_kg), `complete`. Each call writes a typed timeline + event with GPS via `TripExecutor::log()`. +- [x] Module 11: Scanning + Collection Logs — complete + - `collection_logs`. `ScanService::scan()` validates: code state must + be `active` (used→duplicate, expired→expired, otherwise→invalid), + GPS within 200m of DOP via `ST_Distance_Sphere`. On accept: + transitions code to `used`, writes log, increments stop scan count, + fires `qr_scanned` timeline event, dispatches `QrBalanceLow` if + household active count drops below threshold. + - `POST /scanner/scan` and `POST /scanner/scan/bulk` (offline sync; + each scan processed independently). +- [x] Module 12: Partner Stores — complete + - `partner_stores`, `store_inventories`, `store_purchases`, + `store_sales`. Promoted QR-code/batch FKs to partner_stores. + - `StoreOperations::issueWholesale()` generates a fresh batch targeted + at a store, marks codes `allocated` to that store, tops up inventory. + - `StoreOperations::sellToHousehold()` flips N codes from + allocated→active reassigned to the household's id, computes + commission at the store's rate, decrements inventory. +- [x] Module 13: Reports + Analytics — complete (Reports/Analytics + sub-module of Module 13; Payments/Notifications/Live Tracking deferred) + - `daily_collection_stats`, `weekly_route_performance`, + `monthly_store_sales` aggregation tables. + - `App\Services\Report\Aggregator` rebuilds each. Idempotent — + deletes the slice and re-inserts. `php artisan reports:aggregate + --date=YYYY-MM-DD` for nightly job (default: yesterday). + - Admin endpoints: `GET /admin/reports/{daily-collection, + trip-performance,store-sales}` chart-ready (series + totals), + `GET /admin/reports/compliance.csv?from=&to=` streams a DENR-style + dumpsite-release export, `POST /admin/reports/rebuild` triggers + aggregation on demand. +- All 164 feature tests passing +- [ ] Deferred sub-modules: PayMongo payments, FCM notifications, + Reverb live tracking ### Geo notes - Boundary polygons + centroids stored nullable for now. Once a full PSGC diff --git a/app/Console/Commands/AggregateReports.php b/app/Console/Commands/AggregateReports.php new file mode 100644 index 0000000..d770776 --- /dev/null +++ b/app/Console/Commands/AggregateReports.php @@ -0,0 +1,33 @@ +option('date') + ? Carbon::parse($this->option('date')) + : Carbon::yesterday(); + + $daily = $agg->rebuildDailyCollectionStats($date); + $weekly = $agg->rebuildWeeklyRoutePerformance($date); + $monthly = $agg->rebuildMonthlyStoreSales($date); + + $this->info(sprintf( + 'Aggregated for %s — daily:%d weekly:%d monthly:%d', + $date->toDateString(), + $daily, $weekly, $monthly, + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AdminReportController.php b/app/Http/Controllers/Api/V1/Admin/AdminReportController.php new file mode 100644 index 0000000..60791ea --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminReportController.php @@ -0,0 +1,174 @@ +validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'barangay_id' => ['nullable', 'integer'], + ]); + $from = isset($data['from']) ? Carbon::parse($data['from'])->toDateString() : Carbon::now()->subDays(30)->toDateString(); + $to = isset($data['to']) ? Carbon::parse($data['to'])->toDateString() : Carbon::today()->toDateString(); + + $rows = DailyCollectionStat::query() + ->whereBetween('date', [$from, $to]) + ->when($data['barangay_id'] ?? null, fn ($q, $id) => $q->where('barangay_id', $id)) + ->orderBy('date') + ->get(); + + return $this->ok([ + 'from' => $from, + 'to' => $to, + 'series' => $rows->map(fn ($r) => [ + 'date' => $r->date->toDateString(), + 'barangay_id' => $r->barangay_id, + 'total_scans' => $r->total_scans, + 'total_weight_kg' => $r->total_weight_kg, + 'unique_households' => $r->unique_households, + 'missed_pickups' => $r->missed_pickups, + ]), + 'totals' => [ + 'total_scans' => (int) $rows->sum('total_scans'), + 'total_weight_kg' => (int) $rows->sum('total_weight_kg'), + 'unique_households' => (int) $rows->sum('unique_households'), + ], + ]); + } + + public function tripPerformance(Request $request): JsonResponse + { + $data = $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'route_id' => ['nullable', 'integer'], + ]); + $from = isset($data['from']) ? Carbon::parse($data['from'])->toDateString() : Carbon::now()->subWeeks(8)->toDateString(); + $to = isset($data['to']) ? Carbon::parse($data['to'])->toDateString() : Carbon::today()->toDateString(); + + $rows = WeeklyRoutePerformance::with('route') + ->whereBetween('week_start_date', [$from, $to]) + ->when($data['route_id'] ?? null, fn ($q, $id) => $q->where('route_id', $id)) + ->orderBy('week_start_date') + ->get(); + + return $this->ok([ + 'from' => $from, + 'to' => $to, + 'series' => $rows->map(fn ($r) => [ + 'week_start' => $r->week_start_date->toDateString(), + 'route_code' => $r->route?->code, + 'on_time_rate_percent' => $r->on_time_rate_percent, + 'avg_trip_duration_minutes' => $r->avg_trip_duration_minutes, + 'completion_rate_percent' => $r->completion_rate_percent, + 'trips_count' => $r->trips_count, + ]), + ]); + } + + public function storeSales(Request $request): JsonResponse + { + $data = $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'store_id' => ['nullable', 'integer'], + ]); + $from = isset($data['from']) ? Carbon::parse($data['from'])->startOfMonth()->toDateString() : Carbon::now()->subMonths(6)->startOfMonth()->toDateString(); + $to = isset($data['to']) ? Carbon::parse($data['to'])->endOfMonth()->toDateString() : Carbon::today()->endOfMonth()->toDateString(); + + $rows = MonthlyStoreSale::with('store') + ->whereBetween('month_start_date', [$from, $to]) + ->when($data['store_id'] ?? null, fn ($q, $id) => $q->where('store_id', $id)) + ->orderBy('month_start_date') + ->get(); + + return $this->ok([ + 'from' => $from, + 'to' => $to, + 'series' => $rows->map(fn ($r) => [ + 'month' => $r->month_start_date->toDateString(), + 'store_name' => $r->store?->business_name, + 'quantity' => $r->total_quantity_sold, + 'retail_centavos' => $r->total_retail_centavos, + 'commission_centavos' => $r->total_commission_centavos, + ]), + 'totals' => [ + 'quantity' => (int) $rows->sum('total_quantity_sold'), + 'retail_centavos' => (int) $rows->sum('total_retail_centavos'), + 'commission_centavos' => (int) $rows->sum('total_commission_centavos'), + ], + ]); + } + + public function complianceCsv(Request $request): StreamedResponse + { + $data = $request->validate([ + 'from' => ['required', 'date'], + 'to' => ['required', 'date'], + ]); + $from = Carbon::parse($data['from'])->startOfDay(); + $to = Carbon::parse($data['to'])->endOfDay(); + + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="verde-compliance-'.$from->toDateString().'-'.$to->toDateString().'.csv"', + ]; + + return response()->streamDownload(function () use ($from, $to) { + $out = fopen('php://output', 'w'); + fputcsv($out, ['released_at', 'trip_number', 'dumpsite', 'permit_number', 'weight_kg', 'gate_pass', 'attendant']); + + \App\Models\DumpsiteRelease::with(['dumpsite', 'trip']) + ->whereBetween('released_at', [$from, $to]) + ->orderBy('released_at') + ->chunk(500, function ($rows) use ($out) { + foreach ($rows as $r) { + fputcsv($out, [ + $r->released_at?->toIso8601String(), + $r->trip?->trip_number, + $r->dumpsite?->name, + $r->dumpsite?->permit_number, + $r->weight_kg, + $r->gate_pass_number, + $r->dumpsite_attendant_name, + ]); + } + }); + + fclose($out); + }, 'verde-compliance.csv', $headers); + } + + public function rebuild(Request $request): JsonResponse + { + $date = $request->input('date') + ? Carbon::parse($request->input('date')) + : Carbon::yesterday(); + + $daily = $this->aggregator->rebuildDailyCollectionStats($date); + $weekly = $this->aggregator->rebuildWeeklyRoutePerformance($date); + $monthly = $this->aggregator->rebuildMonthlyStoreSales($date); + + return $this->ok([ + 'reference_date' => $date->toDateString(), + 'daily_buckets' => $daily, + 'weekly_buckets' => $weekly, + 'monthly_buckets' => $monthly, + ], 'Aggregations rebuilt'); + } +} diff --git a/app/Models/DailyCollectionStat.php b/app/Models/DailyCollectionStat.php new file mode 100644 index 0000000..ce09764 --- /dev/null +++ b/app/Models/DailyCollectionStat.php @@ -0,0 +1,33 @@ + 'date', + 'total_scans' => 'integer', + 'total_weight_kg' => 'integer', + 'unique_households' => 'integer', + 'missed_pickups' => 'integer', + ]; + } + + public function barangay(): BelongsTo + { + return $this->belongsTo(Barangay::class); + } +} diff --git a/app/Models/MonthlyStoreSale.php b/app/Models/MonthlyStoreSale.php new file mode 100644 index 0000000..1a0ca70 --- /dev/null +++ b/app/Models/MonthlyStoreSale.php @@ -0,0 +1,28 @@ + 'date']; + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } +} diff --git a/app/Models/WeeklyRoutePerformance.php b/app/Models/WeeklyRoutePerformance.php new file mode 100644 index 0000000..c849043 --- /dev/null +++ b/app/Models/WeeklyRoutePerformance.php @@ -0,0 +1,30 @@ + 'date']; + } + + public function route(): BelongsTo + { + return $this->belongsTo(\App\Models\Route::class); + } +} diff --git a/app/Services/Report/Aggregator.php b/app/Services/Report/Aggregator.php new file mode 100644 index 0000000..777c5f2 --- /dev/null +++ b/app/Services/Report/Aggregator.php @@ -0,0 +1,144 @@ + barangay). + */ + public function rebuildDailyCollectionStats(\DateTimeInterface $date): int + { + $day = Carbon::parse($date)->toDateString(); + + $rows = DB::table('collection_logs') + ->join('drop_off_points', 'collection_logs.drop_off_point_id', '=', 'drop_off_points.id') + ->whereDate('collection_logs.scanned_at', $day) + ->where('collection_logs.verification_status', 'valid') + ->groupBy('drop_off_points.barangay_id') + ->selectRaw(' + drop_off_points.barangay_id as barangay_id, + COUNT(*) as total_scans, + COALESCE(SUM(collection_logs.weight_kg), 0) as total_weight_kg, + COUNT(DISTINCT collection_logs.household_id) as unique_households + ') + ->get(); + + DailyCollectionStat::where('date', $day)->delete(); + + $count = 0; + foreach ($rows as $r) { + DailyCollectionStat::create([ + 'date' => $day, + 'barangay_id' => $r->barangay_id, + 'total_scans' => (int) $r->total_scans, + 'total_weight_kg' => (int) $r->total_weight_kg, + 'unique_households' => (int) $r->unique_households, + 'missed_pickups' => 0, // populated by trip-stop analysis below in future + ]); + $count++; + } + + return $count; + } + + /** + * Recompute weekly_route_performance for the week containing $date. + */ + public function rebuildWeeklyRoutePerformance(\DateTimeInterface $date): int + { + $weekStart = Carbon::parse($date)->startOfWeek()->toDateString(); + $weekEnd = Carbon::parse($date)->endOfWeek()->toDateString(); + + $rows = DB::table('trips') + ->whereBetween('scheduled_date', [$weekStart, $weekEnd]) + ->whereIn('status', ['completed', 'cancelled']) + ->groupBy('route_id') + ->selectRaw(' + route_id, + COUNT(*) as trips_count, + SUM(CASE WHEN status = "completed" THEN 1 ELSE 0 END) as completed_count, + AVG(CASE + WHEN actual_end_time IS NOT NULL AND actual_start_time IS NOT NULL + THEN TIMESTAMPDIFF(MINUTE, actual_start_time, actual_end_time) + ELSE NULL + END) as avg_minutes, + SUM(CASE + WHEN status = "completed" + AND actual_start_time IS NOT NULL + AND scheduled_start_time IS NOT NULL + AND TIMESTAMPDIFF( + MINUTE, + TIMESTAMP(scheduled_date, scheduled_start_time), + actual_start_time + ) <= 15 + THEN 1 ELSE 0 + END) as on_time_count + ') + ->get(); + + WeeklyRoutePerformance::where('week_start_date', $weekStart)->delete(); + + $count = 0; + foreach ($rows as $r) { + WeeklyRoutePerformance::create([ + 'week_start_date' => $weekStart, + 'route_id' => $r->route_id, + 'on_time_rate_percent' => $r->trips_count > 0 + ? (int) round(($r->on_time_count / $r->trips_count) * 100) + : 0, + 'avg_trip_duration_minutes' => (int) round((float) ($r->avg_minutes ?? 0)), + 'completion_rate_percent' => $r->trips_count > 0 + ? (int) round(($r->completed_count / $r->trips_count) * 100) + : 0, + 'trips_count' => (int) $r->trips_count, + ]); + $count++; + } + + return $count; + } + + /** + * Recompute monthly_store_sales for the month containing $date. + */ + public function rebuildMonthlyStoreSales(\DateTimeInterface $date): int + { + $monthStart = Carbon::parse($date)->startOfMonth()->toDateString(); + $monthEnd = Carbon::parse($date)->endOfMonth()->toDateString(); + + $rows = DB::table('store_sales') + ->whereBetween('sold_at', [$monthStart.' 00:00:00', $monthEnd.' 23:59:59']) + ->groupBy('store_id') + ->selectRaw(' + store_id, + SUM(quantity) as qty, + SUM(retail_price_centavos) as retail, + SUM(commission_centavos) as commission + ') + ->get(); + + MonthlyStoreSale::where('month_start_date', $monthStart)->delete(); + + $count = 0; + foreach ($rows as $r) { + MonthlyStoreSale::create([ + 'month_start_date' => $monthStart, + 'store_id' => $r->store_id, + 'total_quantity_sold' => (int) $r->qty, + 'total_retail_centavos' => (int) $r->retail, + 'total_commission_centavos' => (int) $r->commission, + ]); + $count++; + } + + return $count; + } +} diff --git a/database/migrations/2026_05_11_100000_create_aggregation_tables.php b/database/migrations/2026_05_11_100000_create_aggregation_tables.php new file mode 100644 index 0000000..5f4a60b --- /dev/null +++ b/database/migrations/2026_05_11_100000_create_aggregation_tables.php @@ -0,0 +1,56 @@ +id(); + $table->date('date'); + $table->foreignId('barangay_id')->nullable()->constrained('barangays')->nullOnDelete(); + $table->unsignedInteger('total_scans')->default(0); + $table->unsignedBigInteger('total_weight_kg')->default(0); + $table->unsignedInteger('unique_households')->default(0); + $table->unsignedInteger('missed_pickups')->default(0); + $table->timestamps(); + + $table->unique(['date', 'barangay_id'], 'dcs_date_brgy_uniq'); + }); + + Schema::create('weekly_route_performance', function (Blueprint $table) { + $table->id(); + $table->date('week_start_date'); + $table->foreignId('route_id')->constrained('routes')->cascadeOnDelete(); + $table->unsignedTinyInteger('on_time_rate_percent')->default(0); + $table->unsignedInteger('avg_trip_duration_minutes')->default(0); + $table->unsignedTinyInteger('completion_rate_percent')->default(0); + $table->unsignedInteger('trips_count')->default(0); + $table->timestamps(); + + $table->unique(['week_start_date', 'route_id'], 'wrp_week_route_uniq'); + }); + + Schema::create('monthly_store_sales', function (Blueprint $table) { + $table->id(); + $table->date('month_start_date'); + $table->foreignId('store_id')->constrained('partner_stores')->cascadeOnDelete(); + $table->unsignedInteger('total_quantity_sold')->default(0); + $table->unsignedBigInteger('total_retail_centavos')->default(0); + $table->unsignedBigInteger('total_commission_centavos')->default(0); + $table->timestamps(); + + $table->unique(['month_start_date', 'store_id'], 'mss_month_store_uniq'); + }); + } + + public function down(): void + { + Schema::dropIfExists('monthly_store_sales'); + Schema::dropIfExists('weekly_route_performance'); + Schema::dropIfExists('daily_collection_stats'); + } +}; diff --git a/routes/api.php b/routes/api.php index bf9149f..8ddbe0b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController; use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController; use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController; use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController; +use App\Http\Controllers\Api\V1\Admin\AdminReportController; use App\Http\Controllers\Api\V1\Admin\AdminRouteController; use App\Http\Controllers\Api\V1\Admin\AdminTeamController; use App\Http\Controllers\Api\V1\Admin\AdminTripController; @@ -203,6 +204,17 @@ Route::prefix('admin/partner-stores') Route::post('/{store}/sales', [AdminPartnerStoreController::class, 'recordSale'])->name('sales'); }); +Route::prefix('admin/reports') + ->name('api.v1.admin.reports.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/daily-collection', [AdminReportController::class, 'dailyCollection'])->name('daily-collection'); + Route::get('/trip-performance', [AdminReportController::class, 'tripPerformance'])->name('trip-performance'); + Route::get('/store-sales', [AdminReportController::class, 'storeSales'])->name('store-sales'); + Route::get('/compliance.csv', [AdminReportController::class, 'complianceCsv'])->name('compliance-csv'); + Route::post('/rebuild', [AdminReportController::class, 'rebuild'])->name('rebuild'); + }); + Route::prefix('admin/trips') ->name('api.v1.admin.trips.') ->middleware(['auth:sanctum', 'role:admin']) diff --git a/tests/Feature/Api/V1/Report/ReportTest.php b/tests/Feature/Api/V1/Report/ReportTest.php new file mode 100644 index 0000000..d039573 --- /dev/null +++ b/tests/Feature/Api/V1/Report/ReportTest.php @@ -0,0 +1,95 @@ +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); + } +}