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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user