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:
48
CLAUDE.md
48
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
|
||||
|
||||
33
app/Console/Commands/AggregateReports.php
Normal file
33
app/Console/Commands/AggregateReports.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Report\Aggregator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class AggregateReports extends Command
|
||||
{
|
||||
protected $signature = 'reports:aggregate {--date= : Reference date (default: yesterday)}';
|
||||
|
||||
protected $description = 'Recompute daily, weekly, and monthly aggregations for the given date';
|
||||
|
||||
public function handle(Aggregator $agg): int
|
||||
{
|
||||
$date = $this->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;
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
33
app/Models/DailyCollectionStat.php
Normal file
33
app/Models/DailyCollectionStat.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DailyCollectionStat extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'date', 'barangay_id', 'total_scans', 'total_weight_kg',
|
||||
'unique_households', 'missed_pickups',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'total_scans' => 'integer',
|
||||
'total_weight_kg' => 'integer',
|
||||
'unique_households' => 'integer',
|
||||
'missed_pickups' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function barangay(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Barangay::class);
|
||||
}
|
||||
}
|
||||
28
app/Models/MonthlyStoreSale.php
Normal file
28
app/Models/MonthlyStoreSale.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MonthlyStoreSale extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'month_start_date', 'store_id',
|
||||
'total_quantity_sold',
|
||||
'total_retail_centavos', 'total_commission_centavos',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['month_start_date' => 'date'];
|
||||
}
|
||||
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PartnerStore::class, 'store_id');
|
||||
}
|
||||
}
|
||||
30
app/Models/WeeklyRoutePerformance.php
Normal file
30
app/Models/WeeklyRoutePerformance.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WeeklyRoutePerformance extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'weekly_route_performance';
|
||||
|
||||
protected $fillable = [
|
||||
'week_start_date', 'route_id',
|
||||
'on_time_rate_percent', 'avg_trip_duration_minutes',
|
||||
'completion_rate_percent', 'trips_count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['week_start_date' => 'date'];
|
||||
}
|
||||
|
||||
public function route(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Route::class);
|
||||
}
|
||||
}
|
||||
144
app/Services/Report/Aggregator.php
Normal file
144
app/Services/Report/Aggregator.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('daily_collection_stats', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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'])
|
||||
|
||||
95
tests/Feature/Api/V1/Report/ReportTest.php
Normal file
95
tests/Feature/Api/V1/Report/ReportTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user