feat(backend): complete Module 13 reports + analytics
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>
This commit is contained in:
174
app/Http/Controllers/Api/V1/Admin/AdminReportController.php
Normal file
174
app/Http/Controllers/Api/V1/Admin/AdminReportController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\DailyCollectionStat;
|
||||
use App\Models\MonthlyStoreSale;
|
||||
use App\Models\WeeklyRoutePerformance;
|
||||
use App\Services\Report\Aggregator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AdminReportController extends ApiController
|
||||
{
|
||||
public function __construct(private readonly Aggregator $aggregator) {}
|
||||
|
||||
public function dailyCollection(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user