Compare commits
3 Commits
fa908cdcaf
...
0e55e36cea
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e55e36cea | |||
| da9cd9bbe0 | |||
| 41c9454334 |
@@ -57,6 +57,13 @@ BOOKING_BACK_SEAT_ENABLED=true
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=true
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
|
||||
|
||||
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
||||
|
||||
SMS_ENABLED=false
|
||||
SMS_SERVER=
|
||||
SMS_TOKEN=
|
||||
SMS_SENDER=
|
||||
|
||||
KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
KBZ_MERCHANT_KEY=
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -21,6 +22,12 @@ class AssignDriverAction
|
||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
if ($booking->travel_date->lt(today())) {
|
||||
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
|
||||
}
|
||||
|
||||
$isFirstAssignment = $booking->driver_name === null;
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => $data->driverName,
|
||||
'driver_phone' => $data->driverPhone,
|
||||
@@ -28,6 +35,13 @@ class AssignDriverAction
|
||||
'car_model' => $data->carModel,
|
||||
]);
|
||||
|
||||
// Guards against a double-submit of the same form resulting in two
|
||||
// identical SMS notifications to the passenger — a genuine
|
||||
// reassignment always changes at least one of these columns.
|
||||
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
|
||||
DriverAssigned::dispatch($booking, $isFirstAssignment);
|
||||
}
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Fired whenever AssignDriverAction sets or updates a booking's
|
||||
* driver/vehicle details — covers both the first assignment and any later
|
||||
* reassignment, since both go through the same action. $isFirstAssignment
|
||||
* lets listeners (e.g. the SMS notification) word the message differently
|
||||
* for "driver assigned" vs "driver info updated".
|
||||
*/
|
||||
class DriverAssigned
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
);
|
||||
}
|
||||
|
||||
public static function travelDateInPast(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ class AssignDriverTableAction
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& $record->travel_date->gte(today())
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
|
||||
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
||||
use Modules\Booking\Http\Resources\BookingResource;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingController extends Controller
|
||||
@@ -50,6 +51,17 @@ class BookingController extends Controller
|
||||
}
|
||||
|
||||
$bookings = $query
|
||||
// Only bookings that actually have a completed payment — a
|
||||
// pending_payment booking never had money move, so it's noise
|
||||
// in a booking list, not a real reservation to show.
|
||||
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
|
||||
// A round trip is two Booking rows (outbound + return leg,
|
||||
// linked via linked_booking_id — domain.md §2b), but it should
|
||||
// still surface once here, not as two separate list entries.
|
||||
// The outbound row's `linked_booking` already carries the
|
||||
// return leg's full detail (including vehicle_options).
|
||||
->where('is_return_leg', false)
|
||||
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
|
||||
@@ -31,6 +31,15 @@ class BookingResource extends JsonResource
|
||||
'dropoff_lat' => $this->dropoff_lat,
|
||||
'dropoff_lng' => $this->dropoff_lng,
|
||||
'price' => $this->price,
|
||||
// This leg's own price, same value CancelBookingAction/
|
||||
// RefundBookingAction use for this specific leg. total_price is
|
||||
// the round-trip total (this leg + linked leg) — computed here,
|
||||
// not left to the client to sum, since it must always match what
|
||||
// InitiatePaymentAction actually charges (bcadd, same as there).
|
||||
// Equal to `price` for a plain one-way booking.
|
||||
'total_price' => $this->relationLoaded('linkedBooking') && $this->linkedBooking !== null
|
||||
? bcadd((string) $this->price, (string) $this->linkedBooking->price, 2)
|
||||
: $this->price,
|
||||
'created_by_channel' => $this->created_by_channel,
|
||||
// Only ever populated once status is confirmed — see AssignDriverAction.
|
||||
'driver_name' => $this->driver_name,
|
||||
@@ -82,6 +91,15 @@ class BookingResource extends JsonResource
|
||||
'line_total' => $selection->line_total,
|
||||
])
|
||||
: null,
|
||||
// Each leg gets its own independent driver/vehicle
|
||||
// assignment — the return leg is never guaranteed the same
|
||||
// car as the outbound leg (domain.md §2b). Only ever
|
||||
// populated once that leg's own status is confirmed — see
|
||||
// AssignDriverAction.
|
||||
'driver_name' => $this->linkedBooking->driver_name,
|
||||
'driver_phone' => $this->linkedBooking->driver_phone,
|
||||
'car_plate_number' => $this->linkedBooking->car_plate_number,
|
||||
'car_model' => $this->linkedBooking->car_model,
|
||||
]),
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Listeners;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
/**
|
||||
* Notifies the passenger of their driver/car details whenever a driver is
|
||||
* assigned or reassigned (domain.md — driver/vehicle assignment). Queued
|
||||
* since it's an outbound HTTP call to the SMS gateway.
|
||||
*/
|
||||
class SendDriverAssignedSms implements ShouldQueue
|
||||
{
|
||||
public function __construct(private readonly SmsService $smsService) {}
|
||||
|
||||
public function handle(DriverAssigned $event): void
|
||||
{
|
||||
$booking = $event->booking;
|
||||
|
||||
$this->smsService->send($booking->passenger_phone, $this->message($event));
|
||||
}
|
||||
|
||||
private function message(DriverAssigned $event): string
|
||||
{
|
||||
$booking = $event->booking;
|
||||
|
||||
$vehicle = trim($booking->car_model !== null
|
||||
? "{$booking->car_plate_number} ({$booking->car_model})"
|
||||
: $booking->car_plate_number);
|
||||
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
|
||||
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
|
||||
|
||||
$appName = 'BNF Express - '.config('app.name');
|
||||
$supportPhone = config('app.support_phone');
|
||||
$supportEmail = config('app.support_email');
|
||||
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
|
||||
|
||||
if ($event->isFirstAssignment) {
|
||||
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||
} else {
|
||||
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||
}
|
||||
|
||||
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace Modules\Booking\Providers;
|
||||
|
||||
use Illuminate\Contracts\Auth\Access\Gate;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
|
||||
class BookingServiceProvider extends ServiceProvider
|
||||
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
|
||||
public function boot(Gate $gate): void
|
||||
{
|
||||
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
|
||||
|
||||
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -11,10 +14,27 @@ beforeEach(function () {
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
/**
|
||||
* Index only ever shows bookings with a completed payment — give the
|
||||
* booking a completed Payment row so it's not silently excluded.
|
||||
*/
|
||||
function paidBooking(array $attributes = []): Booking
|
||||
{
|
||||
$booking = Booking::factory()->create($attributes);
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => $booking->price,
|
||||
]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
test('index lists only the authenticated user\'s own bookings, latest first', function () {
|
||||
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
$mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||
$mineNewer = paidBooking(['user_id' => $this->owner->id]);
|
||||
paidBooking(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
|
||||
->assertJsonPath('data.1.id', $mine->id);
|
||||
});
|
||||
|
||||
test('index excludes bookings with no completed payment', function () {
|
||||
// pending_payment, never paid.
|
||||
Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
// Has a payment attempt, but it failed — still not "complete".
|
||||
$failedPayment = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Payment::factory()->failed()->create(['booking_id' => $failedPayment->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
|
||||
$paid = paidBooking(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $paid->id);
|
||||
});
|
||||
|
||||
test('index surfaces a round trip once, not as two separate rows, with a combined total_price', function () {
|
||||
$outbound = paidBooking(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'price' => '11000.00',
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $outbound->id)
|
||||
->assertJsonPath('data.0.price', '9000.00')
|
||||
->assertJsonPath('data.0.total_price', '20000.00')
|
||||
->assertJsonPath('data.0.linked_booking.id', $return->id);
|
||||
});
|
||||
|
||||
test('linked_booking carries the return leg\'s own driver/vehicle assignment, independent of the outbound leg\'s', function () {
|
||||
$outbound = paidBooking([
|
||||
'user_id' => $this->owner->id,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'MDY-5678',
|
||||
'car_model' => null,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.driver_name', 'U Aung')
|
||||
->assertJsonPath('data.car_plate_number', 'YGN-1234')
|
||||
->assertJsonPath('data.linked_booking.driver_name', 'Daw Hla')
|
||||
->assertJsonPath('data.linked_booking.driver_phone', '+959444555666')
|
||||
->assertJsonPath('data.linked_booking.car_plate_number', 'MDY-5678')
|
||||
->assertJsonPath('data.linked_booking.car_model', null);
|
||||
});
|
||||
|
||||
test('total_price equals price for a plain one-way booking, on both index and show', function () {
|
||||
$booking = paidBooking(['user_id' => $this->owner->id, 'price' => '15000.00']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertJsonPath('data.0.total_price', '15000.00');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertJsonPath('data.total_price', '15000.00');
|
||||
});
|
||||
|
||||
test('show returns the combined total_price for a round trip', function () {
|
||||
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'price' => '11000.00',
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.price', '9000.00')
|
||||
->assertJsonPath('data.total_price', '20000.00');
|
||||
});
|
||||
|
||||
test('index filters by booking_ref, partial and case-insensitive', function () {
|
||||
$match = paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-FINDME1']);
|
||||
paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-OTHER01']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings?booking_ref=findme')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $match->id);
|
||||
});
|
||||
|
||||
test('index rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
||||
});
|
||||
|
||||
@@ -149,6 +149,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
|
||||
->assertTableActionHidden('assignDriver', $pending);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden once the travel date has passed', function () {
|
||||
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
|
||||
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $past)
|
||||
->assertTableActionVisible('assignDriver', $today);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
@@ -4,6 +4,8 @@ use Firebase\JWT\JWT;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
|
||||
|
||||
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
||||
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
||||
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.name' => 'FamousLY4 EV',
|
||||
'app.support_phone' => '+959123456789',
|
||||
'app.support_email' => 'support@famousLY4.test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
|
||||
&& str_contains($message, 'U Aung')
|
||||
&& str_contains($message, 'YGN-1234')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
|
||||
});
|
||||
|
||||
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'YGN-5678',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
|
||||
&& str_contains($message, 'Daw Hla')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
|
||||
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'Daw Hla',
|
||||
driverPhone: '+959444555666',
|
||||
carPlateNumber: 'YGN-5678',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
|
||||
});
|
||||
|
||||
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
carModel: 'Tesla Model Y',
|
||||
));
|
||||
|
||||
Event::assertNotDispatched(DriverAssigned::class);
|
||||
});
|
||||
|
||||
test('car_model is optional', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver when the travel date has already passed', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today()->subDay(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it allows assigning a driver when the travel date is today', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today(),
|
||||
]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
expect($updated->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver to a cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -61,6 +63,11 @@ class ManageAppSettings extends Page
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||
'booking_admin_emails' => config('booking.admin_emails'),
|
||||
'sms_enabled' => (bool) config('services.sms.enabled'),
|
||||
'sms_server' => config('services.sms.sms_poh.server'),
|
||||
'sms_token' => config('services.sms.sms_poh.token'),
|
||||
'sms_sender' => config('services.sms.sms_poh.sender'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -111,7 +118,34 @@ class ManageAppSettings extends Page
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Front Seats a single booking may request.'),
|
||||
TagsInput::make('booking_admin_emails')
|
||||
->label('Admin Emails')
|
||||
->required()
|
||||
->helperText('Notified on booking events. Press enter after each address.'),
|
||||
]),
|
||||
Tab::make('SMS')
|
||||
->schema([
|
||||
Toggle::make('sms_enabled')
|
||||
->label('SMS Enabled')
|
||||
->live()
|
||||
->helperText('Whether driver/car SMS notifications are sent at all.'),
|
||||
TextInput::make('sms_server')
|
||||
->label('SMS Server URL')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_token')
|
||||
->label('SMS Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_sender')
|
||||
->label('SMS Sender')
|
||||
->maxLength(255)
|
||||
->helperText('Default sender name/number for outgoing SMS.'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
])
|
||||
->livewireSubmitHandler('save')
|
||||
@@ -139,6 +173,11 @@ class ManageAppSettings extends Page
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
||||
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
||||
'SMS_SERVER' => $state['sms_server'],
|
||||
'SMS_TOKEN' => $state['sms_token'],
|
||||
'SMS_SENDER' => $state['sms_sender'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
@@ -91,6 +91,7 @@ test('the agent token can still read routes and create/read bookings', function
|
||||
->assertSuccessful();
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->agent->id]);
|
||||
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||
->getJson('/api/v1/bookings')
|
||||
|
||||
@@ -28,6 +28,37 @@ test('a booking status transition is recorded in the audit log', function () {
|
||||
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
|
||||
});
|
||||
|
||||
test('assigning a driver is recorded in the audit log with who and when', function () {
|
||||
$dispatcher = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->actingAs($dispatcher);
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
$activity = Activity::where('subject_type', Booking::class)
|
||||
->where('subject_id', $booking->id)
|
||||
->where('log_name', 'booking')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull();
|
||||
expect($activity->attribute_changes->get('attributes'))->toMatchArray([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
expect($activity->causer_type)->toBe(User::class);
|
||||
expect($activity->causer_id)->toBe($dispatcher->id);
|
||||
expect($activity->created_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a catalog CRUD write is recorded in the audit log', function () {
|
||||
$company = EvCompany::factory()->create(['name' => 'Original Name']);
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
||||
'back_seat_enabled' => false,
|
||||
'whole_vehicle_enabled' => true,
|
||||
'front_seat_max_per_booking' => 2,
|
||||
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
|
||||
'sms_enabled' => true,
|
||||
'sms_server' => 'https://sms.example.test/send',
|
||||
'sms_token' => 'secret-token',
|
||||
'sms_sender' => 'EVBooking',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
@@ -56,7 +61,23 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
||||
->toContain('APP_CURRENCY=MMK')
|
||||
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
||||
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
|
||||
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
|
||||
->toContain('SMS_ENABLED=true')
|
||||
->toContain('SMS_SERVER=https://sms.example.test/send')
|
||||
->toContain('SMS_TOKEN=secret-token')
|
||||
->toContain('SMS_SENDER=EVBooking');
|
||||
});
|
||||
|
||||
test('sms server and token are required once sms is enabled', function () {
|
||||
$superAdmin = User::factory()->create();
|
||||
$superAdmin->assignRole('super_admin');
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
Livewire::test(ManageAppSettings::class)
|
||||
->fillForm(['sms_enabled' => true, 'sms_server' => '', 'sms_token' => ''])
|
||||
->call('save')
|
||||
->assertHasFormErrors(['sms_server', 'sms_token']);
|
||||
});
|
||||
|
||||
test('front seat max per booking must be at least 1', function () {
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
namespace Modules\Payment\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Observers\PaymentObserver;
|
||||
|
||||
@@ -28,9 +23,11 @@ class PaymentServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
||||
|
||||
// MarkBookingPaid/MarkBookingRefunded are auto-discovered by
|
||||
// internachi/modular's EventsPlugin (any Listeners/*.php with a
|
||||
// handle(SomeEvent $event) signature) — registering them here too
|
||||
// used to double-dispatch both listeners (see DriverAssigned's
|
||||
// BookingServiceProvider for the same fix).
|
||||
Payment::observe(PaymentObserver::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Sms;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the sms_poh gateway (the only provider configured
|
||||
* today, config('services.sms')). No-ops when SMS is disabled so callers
|
||||
* (queued listeners) can call send() unconditionally in every environment.
|
||||
*/
|
||||
class SmsService
|
||||
{
|
||||
private readonly bool $enabled;
|
||||
|
||||
private readonly ?string $server;
|
||||
|
||||
private readonly ?string $token;
|
||||
|
||||
private readonly ?string $sender;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $config
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config ??= (array) config('services.sms');
|
||||
$providerConfig = (array) ($config['sms_poh'] ?? []);
|
||||
|
||||
$this->enabled = (bool) ($config['enabled'] ?? false);
|
||||
$this->server = $providerConfig['server'] ?? null;
|
||||
$this->token = $providerConfig['token'] ?? null;
|
||||
$this->sender = $providerConfig['sender'] ?? null;
|
||||
}
|
||||
|
||||
public function send(string $to, string $message, ?string $from = null): bool
|
||||
{
|
||||
if (! $this->enabled || $this->server === null || $this->token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->post($this->server, [
|
||||
'to' => $to,
|
||||
'message' => $message,
|
||||
'from' => $from ?? $this->sender,
|
||||
]);
|
||||
|
||||
Log::notice('Send SMS Response : '.$to.' '.$response->body());
|
||||
|
||||
return $response->successful();
|
||||
} catch (ConnectionException $exception) {
|
||||
Log::error('Send SMS Error : '.$to.' '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
$config = [
|
||||
'enabled' => true,
|
||||
'sms_poh' => [
|
||||
'server' => 'https://sms.test/send',
|
||||
'token' => 'test-token',
|
||||
'sender' => 'FamousLY4',
|
||||
],
|
||||
];
|
||||
|
||||
test('send posts to the configured server with a bearer token and returns true on success', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://sms.test/send'
|
||||
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||
&& $request['to'] === '+959111222333'
|
||||
&& $request['message'] === 'Your driver is here.'
|
||||
&& $request['from'] === 'FamousLY4';
|
||||
});
|
||||
});
|
||||
|
||||
test('send returns false and does not call the gateway when disabled', function () use ($config) {
|
||||
Http::fake();
|
||||
$config['enabled'] = false;
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('send returns false on a non-successful gateway response', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['error' => 'invalid'], 422)]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
test('send uses an explicit from over the configured sender', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
(new SmsService($config))->send('+959111222333', 'Hello', 'OtherSender');
|
||||
|
||||
Http::assertSent(fn ($request) => $request['from'] === 'OtherSender');
|
||||
});
|
||||
@@ -25,6 +25,7 @@ use Modules\Booking\BookingPlugin;
|
||||
use Modules\Catalog\CatalogPlugin;
|
||||
use Modules\Identity\IdentityPlugin;
|
||||
use Modules\Payment\PaymentPlugin;
|
||||
use Modules\Reporting\ReportingPlugin;
|
||||
use Modules\Routing\RoutingPlugin;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
@@ -47,6 +48,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()->label('Catalog'),
|
||||
NavigationGroup::make()->label('Routing'),
|
||||
NavigationGroup::make()->label('Operations'),
|
||||
NavigationGroup::make()->label('Reports'),
|
||||
])
|
||||
->plugins([
|
||||
CatalogPlugin::make(),
|
||||
@@ -54,6 +56,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
BookingPlugin::make(),
|
||||
PaymentPlugin::make(),
|
||||
IdentityPlugin::make(),
|
||||
ReportingPlugin::make(),
|
||||
// T6.5 — ops convenience for browsing storage/logs/*.log
|
||||
// in-browser; distinct from the structured, per-model audit
|
||||
// trail (AuditLogResource, T6.2). No extra permission gate:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"modules/catalog": "*",
|
||||
"modules/identity": "*",
|
||||
"modules/payment": "*",
|
||||
"modules/reporting": "*",
|
||||
"modules/routing": "*",
|
||||
"modules/shared": "*",
|
||||
"spatie/laravel-activitylog": "^5.0",
|
||||
|
||||
Generated
+419
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "323e86b8a07e30ef4c9ed6f5a2f01b8f",
|
||||
"content-hash": "b3018ca42fa6d16d0da6113b3aa6b1a8",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -4316,6 +4316,173 @@
|
||||
],
|
||||
"time": "2026-08-10T15:24:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maatwebsite/excel",
|
||||
"version": "4.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||
"reference": "5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8",
|
||||
"reference": "5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/semver": "^3.4",
|
||||
"illuminate/support": "^12.0 || ^13.0",
|
||||
"php": "^8.3",
|
||||
"phpoffice/phpspreadsheet": "^5.8",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.20",
|
||||
"driftingly/rector-laravel": "^2.5",
|
||||
"ext-sqlite3": "*",
|
||||
"larastan/larastan": "^3.10",
|
||||
"laravel/pint": "^1.29",
|
||||
"laravel/scout": "^10.25 || ^11.2",
|
||||
"orchestra/testbench": "^10.11 || ^11.1",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan-mockery": "^2.0",
|
||||
"phpunit/phpunit": "^12.5 || ~13.1.14",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.4.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
|
||||
},
|
||||
"providers": [
|
||||
"Maatwebsite\\Excel\\ExcelServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Maatwebsite\\Excel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Patrick Brouwers",
|
||||
"email": "patrick@spartner.nl"
|
||||
}
|
||||
],
|
||||
"description": "Supercharged Excel exports and imports in Laravel",
|
||||
"keywords": [
|
||||
"PHPExcel",
|
||||
"batch",
|
||||
"csv",
|
||||
"excel",
|
||||
"export",
|
||||
"import",
|
||||
"laravel",
|
||||
"php",
|
||||
"phpspreadsheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/4.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://laravel-excel.com/commercial-support",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/patrickbrouwers",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-18T12:32:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "marc-mabe/php-enum",
|
||||
"version": "v4.7.2",
|
||||
@@ -4389,6 +4556,113 @@
|
||||
},
|
||||
"time": "2025-09-14T11:18:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "modules/booking",
|
||||
"version": "1.0",
|
||||
@@ -4517,6 +4791,41 @@
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modules/reporting",
|
||||
"version": "1.0",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "app-modules/reporting",
|
||||
"reference": "39c235e3c324b47b1c890e9aedd07044b670aad8"
|
||||
},
|
||||
"require": {
|
||||
"maatwebsite/excel": "^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Reporting\\": "src/",
|
||||
"Modules\\Reporting\\Tests\\": "tests/",
|
||||
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"proprietary"
|
||||
],
|
||||
"transport-options": {
|
||||
"symlink": true,
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modules/routing",
|
||||
"version": "1.0",
|
||||
@@ -5327,6 +5636,115 @@
|
||||
},
|
||||
"time": "2025-09-24T15:06:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.2",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"ext-intl": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.5",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
},
|
||||
{
|
||||
"name": "Owen Leibman"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
||||
},
|
||||
"time": "2026-07-12T19:17:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
|
||||
@@ -40,6 +40,15 @@ return [
|
||||
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
||||
],
|
||||
|
||||
'sms' => [
|
||||
'enabled' => env('SMS_ENABLED', false),
|
||||
'sms_poh' => [
|
||||
'server' => env('SMS_SERVER'),
|
||||
'token' => env('SMS_TOKEN'),
|
||||
'sender' => env('SMS_SENDER'),
|
||||
],
|
||||
],
|
||||
|
||||
'kbz' => [
|
||||
'app_id' => env('KBZ_APP_ID'),
|
||||
'merchant_code' => env('KBZ_MERCHANT_CODE'),
|
||||
|
||||
Reference in New Issue
Block a user