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>
145 lines
5.2 KiB
PHP
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;
|
|
}
|
|
}
|