Add Bookings & Revenue reporting module
PHP Tests / php-tests (push) Has been cancelled

New modules/reporting Filament page: filterable bookings table (travel
date range, status, route, channel) with CSV/Excel export. Report
columns include booking ref, route, passenger name/count, price,
best-payment status/amount, and driver info, plus a TOTAL row summing
passenger count, price, and payment amount in both export formats.

- BookingsRevenueExport backs both CSV and XLSX via maatwebsite/excel
  ^4.0 (the only version compatible with PHP 8.5; 3.1.x caps
  phpoffice/phpspreadsheet below 8.5).
- CSV export writes a UTF-8 BOM so non-Latin passenger names (Burmese)
  open correctly in Excel.
- New view_reports permission (super_admin/admin/support) gates the
  page; new indexes on bookings.travel_date/status/created_by_channel
  and payments.completed_at support the report's filters.
This commit is contained in:
Nyan Lin Paing
2026-08-23 22:32:49 +07:00
parent da9cd9bbe0
commit 0e55e36cea
12 changed files with 956 additions and 1 deletions
@@ -25,6 +25,7 @@ class RolePermissionSeeder extends Seeder
'manage_roles',
'view_customers',
'manage_settings',
'view_reports',
];
/**
@@ -44,6 +45,7 @@ class RolePermissionSeeder extends Seeder
'manage_roles',
'view_customers',
'manage_settings',
'view_reports',
],
'admin' => [
'manage_catalog',
@@ -56,6 +58,7 @@ class RolePermissionSeeder extends Seeder
'view_audit_log',
'view_customers',
'manage_settings',
'view_reports',
],
'support' => [
'view_bookings',
+26
View File
@@ -0,0 +1,26 @@
{
"name": "modules/reporting",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {
"maatwebsite/excel": "^4.0"
},
"autoload": {
"psr-4": {
"Modules\\Reporting\\": "src/",
"Modules\\Reporting\\Tests\\": "tests/",
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
]
}
}
}
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Supports the Bookings & Revenue report's filters travel_date/status/
* created_by_channel on bookings and completed_at on payments had no
* standalone index before this (only openid and the composite
* [ev_route_id, travel_date, departure_time_slot_id] existed).
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->index('travel_date');
$table->index('status');
$table->index('created_by_channel');
});
Schema::table('payments', function (Blueprint $table) {
$table->index('completed_at');
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropIndex(['travel_date']);
$table->dropIndex(['status']);
$table->dropIndex(['created_by_channel']);
});
Schema::table('payments', function (Blueprint $table) {
$table->dropIndex(['completed_at']);
});
}
};
@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->filtersForm }}
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,134 @@
<?php
namespace Modules\Reporting\Exports;
use Illuminate\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Events\AfterSheet;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
/**
* One row per Booking, with its "best" Payment (completed, else most recent)
* joined on, plus a bold TOTAL row summing passenger count, price, and
* payment amount. Backs both the CSV and Excel exports of the Bookings &
* Revenue report Excel::download() picks the writer, this class supplies
* the columns once for both formats.
*/
class BookingsRevenueExport implements FromQuery, ShouldAutoSize, WithCustomCsvSettings, WithEvents, WithHeadings, WithMapping
{
public function __construct(private readonly Builder $query) {}
public function query(): Builder
{
return $this->query;
}
/**
* @return array<int, string>
*/
public function headings(): array
{
return [
'Booking Ref', 'Travel Date', 'Route', 'Channel', 'Status',
'Passenger Name', 'Passenger Count', 'Price', 'Payment Status',
'Payment Amount', 'Driver Name', 'Driver Phone', 'Car Plate',
'Car Model', 'Vehicle Options',
];
}
/**
* @return array<int, mixed>
*/
public function map($booking): array
{
/** @var Booking $booking */
$payment = $this->bestPayment($booking);
return [
$booking->booking_ref,
$booking->travel_date?->toDateString(),
$booking->route ? $booking->route->name : '',
$booking->created_by_channel?->value,
$booking->status->value,
$booking->passenger_name,
$booking->vehicleOptions->sum('passenger_count'),
(float) $booking->price,
$payment?->status?->value ?? '',
$payment ? (float) $payment->amount : null,
$booking->driver_name,
$booking->driver_phone,
$booking->car_plate_number,
$booking->car_model,
$booking->vehicleOptions
->map(fn ($v) => str($v->vehicle_option->value)->headline().' x'.$v->passenger_count)
->implode('; '),
];
}
/**
* Appends a bold TOTAL row (passenger count, price, payment amount)
* below the last data row. Re-fetches the already-filtered query rather
* than accumulating during map() FromQuery streams rows in chunks, so
* there's no single point with the full result set to total as it's
* written; report-sized result sets make a second fetch cheap enough to
* trade for keeping the chunked write untouched.
*
* @return array<string, callable>
*/
public function registerEvents(): array
{
return [
AfterSheet::class => function (AfterSheet $event): void {
$bookings = (clone $this->query)->get();
$totalPassengers = $bookings->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count'));
$totalPrice = $bookings->sum('price');
$totalPaid = $bookings->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0);
$worksheet = $event->getDelegate();
$row = $worksheet->getHighestRow() + 1;
$lastColumn = Coordinate::stringFromColumnIndex(count($this->headings()));
$worksheet->setCellValue("A{$row}", 'TOTAL');
$worksheet->setCellValue($this->columnFor('Passenger Count').$row, $totalPassengers);
$worksheet->setCellValue($this->columnFor('Price').$row, $totalPrice);
$worksheet->setCellValue($this->columnFor('Payment Amount').$row, $totalPaid);
$worksheet->getStyle("A{$row}:{$lastColumn}{$row}")->getFont()->setBold(true);
},
];
}
private function columnFor(string $heading): string
{
return Coordinate::stringFromColumnIndex(array_search($heading, $this->headings(), true) + 1);
}
private function bestPayment(Booking $booking): ?Payment
{
return $booking->payments->sortByDesc(
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
)->first();
}
/**
* Excel's CSV import guesses encoding from the system locale unless a
* UTF-8 BOM is present, so passenger names/routes containing Burmese
* (or other non-Latin) text open correctly instead of as mojibake.
*
* @return array<string, mixed>
*/
public function getCsvSettings(): array
{
return [
'use_bom' => true,
];
}
}
@@ -0,0 +1,195 @@
<?php
namespace Modules\Reporting\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Maatwebsite\Excel\Excel as ExcelFormat;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
use Modules\Reporting\Exports\BookingsRevenueExport;
use Modules\Routing\Models\EvRoute;
use UnitEnum;
class BookingsRevenueReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentChartBar;
protected static string|UnitEnum|null $navigationGroup = 'Reports';
protected static ?string $navigationLabel = 'Bookings & Revenue';
protected static ?string $title = 'Bookings & Revenue Report';
protected string $view = 'reporting::filament.pages.bookings-revenue-report';
/**
* @var array<string, mixed>|null
*/
public ?array $filters = [];
public static function canAccess(): bool
{
return auth()->user()?->can('view_reports') ?? false;
}
public function mount(): void
{
$this->filtersForm->fill();
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->components([
DatePicker::make('travel_date_from')
->label('Travel date from')
->live(),
DatePicker::make('travel_date_to')
->label('Travel date to')
->afterOrEqual('travel_date_from')
->live(),
Select::make('status')
->label('Status')
->options(BookingStatus::class)
->native(false)
->placeholder('All statuses')
->live(),
Select::make('ev_route_id')
->label('Route')
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
->mapWithKeys(fn (EvRoute $route) => [$route->id => $route->name]))
->searchable()
->placeholder('All routes')
->live(),
Select::make('created_by_channel')
->label('Channel')
->options(BookingChannel::class)
->native(false)
->placeholder('All channels')
->live(),
])
->columns(3)
->statePath('filters');
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->reportQuery())
->columns([
TextColumn::make('booking_ref')
->label('Ref')
->sortable(),
TextColumn::make('travel_date')
->date()
->sortable(),
TextColumn::make('route.name')
->label('Route'),
TextColumn::make('created_by_channel')
->badge(),
TextColumn::make('status')
->badge(),
TextColumn::make('passenger_name')
->label('Passenger'),
TextColumn::make('passenger_count')
->label('Pax')
->state(fn (Booking $record) => $record->vehicleOptions->sum('passenger_count'))
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('vehicleOptions')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count')))),
TextColumn::make('price')
->numeric(2)
->sortable()
->summarize(Sum::make()->label('Total')),
TextColumn::make('payment_status')
->label('Payment')
->state(fn (Booking $record) => $this->bestPayment($record)?->status?->value ?? '—'),
TextColumn::make('payment_amount')
->label('Paid')
->state(fn (Booking $record) => $this->bestPayment($record)?->amount)
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('payments')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0))),
TextColumn::make('driver_name')
->label('Driver')
->placeholder('—'),
])
->defaultSort('travel_date', 'desc')
->paginated([25, 50, 100]);
}
public function reportQuery(): Builder
{
$data = $this->filters ?? [];
return Booking::query()
->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions'])
->when($data['travel_date_from'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '>=', $d))
->when($data['travel_date_to'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '<=', $d))
->when($data['status'] ?? null, fn (Builder $q, $s) => $q->where('status', $s))
->when($data['ev_route_id'] ?? null, fn (Builder $q, $id) => $q->where('ev_route_id', $id))
->when($data['created_by_channel'] ?? null, fn (Builder $q, $c) => $q->where('created_by_channel', $c))
// A unique tie-breaker after travel_date — required for FromQuery's
// chunked export to paginate deterministically (see its docblock);
// the table's own defaultSort() applies on top of this for display.
->orderBy('travel_date', 'desc')
->orderBy('id');
}
protected function bestPayment(Booking $record): ?Payment
{
return $record->payments->sortByDesc(
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
)->first();
}
protected function getHeaderActions(): array
{
return [
Action::make('exportCsv')
->label('Export CSV')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.csv',
ExcelFormat::CSV,
)),
Action::make('exportXlsx')
->label('Export Excel')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.xlsx',
ExcelFormat::XLSX,
)),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Reporting\Providers;
use Illuminate\Support\ServiceProvider;
class ReportingServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Reporting;
use Filament\Contracts\Plugin;
use Filament\Panel;
class ReportingPlugin implements Plugin
{
public function getId(): string
{
return 'reporting';
}
public function register(Panel $panel): void
{
$panel->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\Reporting\Filament\Pages',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,89 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Maatwebsite\Excel\Excel as ExcelFormat;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Reporting\Exports\BookingsRevenueExport;
use Modules\Reporting\Filament\Pages\BookingsRevenueReport;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
Permission::findOrCreate('view_reports', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['view_reports']);
$this->actingAs($this->admin);
});
test('it renders for a user with view_reports', function () {
Livewire::test(BookingsRevenueReport::class)->assertOk();
});
test('a user without view_reports cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(BookingsRevenueReport::canAccess())->toBeFalse();
});
test('it narrows results by travel date range and status', function () {
$inRange = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
$outOfRange = Booking::factory()->create(['travel_date' => today()->addMonths(2), 'status' => BookingStatus::Confirmed]);
$wrongStatus = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Cancelled]);
Livewire::test(BookingsRevenueReport::class)
->fillForm([
'travel_date_from' => today()->toDateString(),
'travel_date_to' => today()->toDateString(),
'status' => BookingStatus::Confirmed->value,
], 'filtersForm')
->assertCanSeeTableRecords([$inRange])
->assertCanNotSeeTableRecords([$outOfRange, $wrongStatus]);
});
test('exporting csv triggers a download', function () {
Excel::fake();
Booking::factory()->create();
Livewire::test(BookingsRevenueReport::class)->callAction('exportCsv');
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.csv');
});
test('exporting excel triggers a download', function () {
Excel::fake();
Booking::factory()->create();
Livewire::test(BookingsRevenueReport::class)->callAction('exportXlsx');
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.xlsx');
});
test('the export includes passenger name/count columns and a total row', function () {
$a = Booking::factory()->create(['passenger_name' => 'Jane Doe', 'price' => 10000]);
$a->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 2, 'unit_price' => 5000, 'line_total' => 10000]);
$b = Booking::factory()->create(['passenger_name' => 'John Roe', 'price' => 15000]);
$b->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 3, 'unit_price' => 5000, 'line_total' => 15000]);
$export = new BookingsRevenueExport(Booking::query()->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions']));
$path = storage_path('app/test-bookings-revenue.xlsx');
file_put_contents($path, Excel::raw($export, ExcelFormat::XLSX));
$sheet = IOFactory::load($path)->getActiveSheet();
unlink($path);
expect($sheet->getCell('F1')->getValue())->toBe('Passenger Name')
->and($sheet->getCell('G1')->getValue())->toBe('Passenger Count')
->and([$sheet->getCell('F2')->getValue(), $sheet->getCell('F3')->getValue()])->toContain('Jane Doe', 'John Roe');
$totalRow = $sheet->getHighestRow();
expect($sheet->getCell("A{$totalRow}")->getValue())->toBe('TOTAL')
->and((int) $sheet->getCell("G{$totalRow}")->getValue())->toBe(5) // 2 + 3 passengers
->and((float) $sheet->getCell("H{$totalRow}")->getValue())->toBe(25000.0); // 10000 + 15000 price
});