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
@@ -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,
];
}
}