Files
Verde-Web/app/Services/Report/Aggregator.php
admin 0a2e187f45 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>
2026-04-30 02:58:09 +08:00

145 lines
5.2 KiB
PHP

<?php
namespace App\Services\Report;
use App\Models\DailyCollectionStat;
use App\Models\MonthlyStoreSale;
use App\Models\WeeklyRoutePerformance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class Aggregator
{
/**
* Recompute daily_collection_stats for a single date. Idempotent.
* Groups by barangay (derived via DOP -> 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;
}
}