This commit is contained in:
@@ -29,6 +29,8 @@ class BookingFactory extends Factory
|
||||
'user_id' => null,
|
||||
'openid' => null,
|
||||
'ev_route_id' => EvRoute::factory(),
|
||||
'linked_booking_id' => null,
|
||||
'is_return_leg' => false,
|
||||
'departure_time_slot_id' => DepartureTimeSlot::factory(),
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'passenger_name' => $this->faker->name(),
|
||||
@@ -41,8 +43,6 @@ class BookingFactory extends Factory
|
||||
'dropoff_lng' => null,
|
||||
'price' => $this->faker->randomFloat(2, 5000, 50000),
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => false,
|
||||
'return_travel_date' => null,
|
||||
'created_by_channel' => BookingChannel::MiniApp,
|
||||
'driver_name' => null,
|
||||
'driver_phone' => null,
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 'notes' — customer-supplied, submitted via the booking create API
|
||||
* endpoint (StoreBookingRequest). 'remark' — staff-only, set from the
|
||||
* admin panel (SetRemarkTableAction); never exposed on the customer
|
||||
* BookingResource. Both nullable, free text.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('remark')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['notes', 'remark']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+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
|
||||
{
|
||||
/**
|
||||
* Round trip is redesigned as two linked one-way Booking rows (outbound
|
||||
* + return) rather than a flag + a lone return date on a single row —
|
||||
* the return leg needs its own route/time-slot/price/driver-vehicle
|
||||
* assignment, since it may run with a different vehicle than the
|
||||
* outbound leg (domain.md §2b). `is_round_trip` becomes a computed
|
||||
* accessor on the model (`linked_booking_id !== null`), so the column
|
||||
* is dropped rather than kept redundant.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_round_trip', 'return_travel_date']);
|
||||
$table->foreignId('linked_booking_id')->nullable()->after('ev_route_id')
|
||||
->constrained('bookings')->nullOnDelete();
|
||||
$table->boolean('is_return_leg')->default(false)->after('linked_booking_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('linked_booking_id');
|
||||
$table->dropColumn('is_return_leg');
|
||||
$table->boolean('is_round_trip')->default(false);
|
||||
$table->date('return_travel_date')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidReturnRouteException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
@@ -26,50 +27,110 @@ class CreateBookingAction
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$route = EvRoute::findOrFail($data->evRouteId);
|
||||
$isRoundTrip = $data->returnEvRouteId !== null;
|
||||
|
||||
$lines = array_map(
|
||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||
$data->selections,
|
||||
if ($isRoundTrip) {
|
||||
$this->bookingService->validateSelections($data->returnSelections);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $isRoundTrip) {
|
||||
$outboundRoute = EvRoute::findOrFail($data->evRouteId);
|
||||
|
||||
$outboundBooking = $this->createLeg(
|
||||
data: $data,
|
||||
route: $outboundRoute,
|
||||
selections: $data->selections,
|
||||
travelDate: $data->travelDate,
|
||||
timeSlotId: $data->departureTimeSlotId,
|
||||
isReturnLeg: false,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
$lines,
|
||||
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
||||
'0.00',
|
||||
if (! $isRoundTrip) {
|
||||
BookingCreated::dispatch($outboundBooking);
|
||||
|
||||
return $outboundBooking;
|
||||
}
|
||||
|
||||
$returnRoute = EvRoute::findOrFail($data->returnEvRouteId);
|
||||
|
||||
if (! $returnRoute->isReverseOf($outboundRoute)) {
|
||||
throw InvalidReturnRouteException::notReverseOfOutbound($returnRoute, $outboundRoute);
|
||||
}
|
||||
|
||||
$returnBooking = $this->createLeg(
|
||||
data: $data,
|
||||
route: $returnRoute,
|
||||
selections: $data->returnSelections,
|
||||
travelDate: $data->returnTravelDate,
|
||||
timeSlotId: $data->returnDepartureTimeSlotId,
|
||||
isReturnLeg: true,
|
||||
);
|
||||
|
||||
$booking = Booking::create([
|
||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||
'user_id' => $data->userId,
|
||||
'openid' => $data->openid,
|
||||
'ev_route_id' => $data->evRouteId,
|
||||
'departure_time_slot_id' => $data->departureTimeSlotId,
|
||||
'travel_date' => $data->travelDate,
|
||||
'passenger_name' => $data->passengerName,
|
||||
'passenger_phone' => $data->passengerPhone,
|
||||
'pickup_address' => $data->pickupAddress,
|
||||
'pickup_lat' => $data->pickupLat,
|
||||
'pickup_lng' => $data->pickupLng,
|
||||
'dropoff_address' => $data->dropoffAddress,
|
||||
'dropoff_lat' => $data->dropoffLat,
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => $data->isRoundTrip,
|
||||
'return_travel_date' => $data->returnTravelDate,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
// Linked bidirectionally after both rows exist — a single
|
||||
// `linked_booking_id` FK can't be set on either row at create
|
||||
// time since the other side doesn't have an id yet.
|
||||
$returnBooking->update(['linked_booking_id' => $outboundBooking->id]);
|
||||
$outboundBooking->update(['linked_booking_id' => $returnBooking->id]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
// No registered listeners on BookingCreated today, so firing it
|
||||
// twice per round-trip creation has no side effects — flagged
|
||||
// here for whoever adds the first listener.
|
||||
BookingCreated::dispatch($outboundBooking);
|
||||
BookingCreated::dispatch($returnBooking);
|
||||
|
||||
BookingCreated::dispatch($booking);
|
||||
|
||||
return $booking;
|
||||
return $outboundBooking->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections
|
||||
*/
|
||||
private function createLeg(
|
||||
CreateBookingData $data,
|
||||
EvRoute $route,
|
||||
array $selections,
|
||||
string $travelDate,
|
||||
int $timeSlotId,
|
||||
bool $isReturnLeg,
|
||||
): Booking {
|
||||
$lines = array_map(
|
||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||
$selections,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
$lines,
|
||||
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
||||
'0.00',
|
||||
);
|
||||
|
||||
$booking = Booking::create([
|
||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||
'user_id' => $data->userId,
|
||||
'openid' => $data->openid,
|
||||
'ev_route_id' => $route->id,
|
||||
'is_return_leg' => $isReturnLeg,
|
||||
'departure_time_slot_id' => $timeSlotId,
|
||||
'travel_date' => $travelDate,
|
||||
'passenger_name' => $data->passengerName,
|
||||
'passenger_phone' => $data->passengerPhone,
|
||||
'notes' => $data->notes,
|
||||
'pickup_address' => $data->pickupAddress,
|
||||
'pickup_lat' => $data->pickupLat,
|
||||
'pickup_lng' => $data->pickupLng,
|
||||
'dropoff_address' => $data->dropoffAddress,
|
||||
'dropoff_lat' => $data->dropoffLat,
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Staff-only internal note, set from the admin panel
|
||||
* (SetRemarkTableAction). No status restriction — staff can annotate a
|
||||
* booking at any point in its lifecycle. Never exposed on the customer
|
||||
* BookingResource.
|
||||
*/
|
||||
class SetRemarkAction
|
||||
{
|
||||
public function handle(Booking $booking, ?string $remark): Booking
|
||||
{
|
||||
$booking->update(['remark' => $remark]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ readonly class CreateBookingData
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections One or more Vehicle Option
|
||||
* selections (e.g. front_seat + back_seat) — domain.md §2.
|
||||
* @param list<VehicleSelectionData>|null $returnSelections Same shape as $selections,
|
||||
* priced independently against $returnEvRouteId. Presence of
|
||||
* $returnEvRouteId is the round-trip signal (domain.md §2b).
|
||||
*/
|
||||
public function __construct(
|
||||
public int $evRouteId,
|
||||
@@ -22,11 +25,14 @@ readonly class CreateBookingData
|
||||
public BookingChannel $createdByChannel,
|
||||
public ?int $userId = null,
|
||||
public ?string $openid = null,
|
||||
public ?string $notes = null,
|
||||
public ?float $pickupLat = null,
|
||||
public ?float $pickupLng = null,
|
||||
public ?float $dropoffLat = null,
|
||||
public ?float $dropoffLng = null,
|
||||
public bool $isRoundTrip = false,
|
||||
public ?int $returnEvRouteId = null,
|
||||
public ?int $returnDepartureTimeSlotId = null,
|
||||
public ?string $returnTravelDate = null,
|
||||
public ?array $returnSelections = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidReturnRouteException extends RuntimeException
|
||||
{
|
||||
public static function notReverseOfOutbound(EvRoute $returnRoute, EvRoute $outboundRoute): self
|
||||
{
|
||||
return new self(
|
||||
"Return route [{$returnRoute->id}] is not the reverse of outbound route [{$outboundRoute->id}] — ".
|
||||
'from/to destinations must be swapped.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected return route is a client input problem, not a server
|
||||
* error — surface it as 422, matching InvalidVehicleSelectionException.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\SetRemarkAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class SetRemarkTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('setRemark')
|
||||
->label('Remark')
|
||||
->icon(Heroicon::OutlinedPencilSquare)
|
||||
->color('gray')
|
||||
->visible(fn (): bool => auth()->user()?->can('manage_bookings') ?? false)
|
||||
->schema([
|
||||
Textarea::make('remark')->maxLength(1000),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'remark' => $record->remark,
|
||||
])
|
||||
->action(function (array $data, Booking $record, SetRemarkAction $setRemarkAction) {
|
||||
$setRemarkAction->handle($record, $data['remark'] ?: null);
|
||||
|
||||
Notification::make()
|
||||
->title('Remark saved')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ViewBooking extends ViewRecord
|
||||
@@ -15,6 +16,7 @@ class ViewBooking extends ViewRecord
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class BookingInfolist
|
||||
@@ -32,7 +33,8 @@ class BookingInfolist
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
@@ -43,10 +45,17 @@ class BookingInfolist
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
TextEntry::make('return_travel_date')->date()
|
||||
TextEntry::make('is_return_leg')->label('Leg')->badge()
|
||||
->formatStateUsing(fn (bool $state) => $state ? 'Return' : 'Outbound')
|
||||
->visible(fn ($record) => $record->is_round_trip),
|
||||
TextEntry::make('linkedBooking.booking_ref')->label('Linked Leg')
|
||||
->visible(fn ($record) => $record->is_round_trip)
|
||||
->url(fn ($record) => $record->linked_booking_id
|
||||
? BookingResource::getUrl('view', ['record' => $record->linked_booking_id])
|
||||
: null),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Vehicle Options')
|
||||
->schema([
|
||||
RepeatableEntry::make('vehicleOptions')
|
||||
@@ -61,15 +70,29 @@ class BookingInfolist
|
||||
]),
|
||||
]),
|
||||
TextEntry::make('price')->label('Total Price')->numeric(2),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Passenger')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('passenger_name'),
|
||||
TextEntry::make('passenger_phone'),
|
||||
TextEntry::make('notes')
|
||||
->label('Customer Notes')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Staff Remark')
|
||||
->description('Internal only — never shown to the customer. Set via the Remark action.')
|
||||
->schema([
|
||||
TextEntry::make('remark')
|
||||
->label('')
|
||||
->placeholder('No remark yet.'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Pickup & Dropoff')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
@@ -81,7 +104,8 @@ class BookingInfolist
|
||||
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Driver & Vehicle')
|
||||
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
|
||||
->schema([
|
||||
@@ -92,7 +116,8 @@ class BookingInfolist
|
||||
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
// A booking can have more than one payment attempt if an
|
||||
// earlier one failed and the customer retried (domain.md §1)
|
||||
// — full detail (gateway response, refunds) lives on the
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Modules\Booking\Filament\Resources\Bookings\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
@@ -15,6 +17,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
@@ -54,6 +57,10 @@ class BookingsTable
|
||||
->sortable(),
|
||||
TextColumn::make('timeSlot.label')
|
||||
->label('Time Slot'),
|
||||
IconColumn::make('is_round_trip')
|
||||
->label('Round Trip')
|
||||
->boolean()
|
||||
->toggleable(),
|
||||
TextColumn::make('vehicleOptions')
|
||||
->label('Vehicle Options')
|
||||
->state(fn (Booking $record) => $record->vehicleOptions
|
||||
@@ -79,6 +86,16 @@ class BookingsTable
|
||||
->join(' • ') ?: null)
|
||||
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->toggleable(),
|
||||
TextColumn::make('notes')
|
||||
->label('Customer Notes')
|
||||
->placeholder('—')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('remark')
|
||||
->label('Staff Remark')
|
||||
->placeholder('—')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@@ -112,6 +129,15 @@ class BookingsTable
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
// is_round_trip is a computed accessor (linked_booking_id
|
||||
// !== null), not a DB column — TernaryFilter builds a raw
|
||||
// where() on it, which breaks now that the column is gone.
|
||||
Filter::make('is_round_trip')
|
||||
->schema([Toggle::make('is_round_trip')])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['is_round_trip'] ?? null,
|
||||
fn (Builder $q) => $q->whereNotNull('linked_booking_id'),
|
||||
)),
|
||||
// Deleted bookings are soft-deleted, not hard-removed
|
||||
// (domain.md; T7.x follow-up) — this is the only place they
|
||||
// become visible again, off by default.
|
||||
@@ -120,6 +146,7 @@ class BookingsTable
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
DeleteBookingTableAction::make(),
|
||||
RestoreBookingTableAction::make(),
|
||||
|
||||
@@ -22,7 +22,11 @@ class BookingController extends Controller
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions'];
|
||||
private const EAGER_LOADS = [
|
||||
'route', 'timeSlot', 'vehicleOptions',
|
||||
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
|
||||
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private CreateBookingAction $createBookingAction,
|
||||
@@ -83,6 +87,18 @@ class BookingController extends Controller
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
$isRoundTrip = $validated['is_round_trip'] ?? false;
|
||||
|
||||
$returnSelections = $isRoundTrip
|
||||
? array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['return_selections'],
|
||||
)
|
||||
: null;
|
||||
|
||||
// The agent's own auth path always wins over anything a header could
|
||||
// claim; customer channels come from Device-Type, not a
|
||||
// client-supplied body field (BookingChannel::fromDeviceTypeHeader
|
||||
@@ -98,6 +114,7 @@ class BookingController extends Controller
|
||||
selections: $selections,
|
||||
passengerName: $validated['passenger_name'],
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
notes: $validated['notes'] ?? null,
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: $channel,
|
||||
@@ -110,8 +127,10 @@ class BookingController extends Controller
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
dropoffLng: $validated['dropoff_lng'] ?? null,
|
||||
isRoundTrip: $validated['is_round_trip'] ?? false,
|
||||
returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null,
|
||||
returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null,
|
||||
returnTravelDate: $validated['return_travel_date'] ?? null,
|
||||
returnSelections: $returnSelections,
|
||||
));
|
||||
|
||||
return (new BookingResource($booking->load(self::EAGER_LOADS)))
|
||||
|
||||
@@ -34,14 +34,24 @@ class StoreBookingRequest extends FormRequest
|
||||
'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
|
||||
'passenger_name' => ['required', 'string', 'max:255'],
|
||||
'passenger_phone' => ['required', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'pickup_address' => ['required', 'string', 'max:500'],
|
||||
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'dropoff_address' => ['required', 'string', 'max:500'],
|
||||
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
// Round trip = a second, independently-priced leg on its own
|
||||
// route/time-slot/date — the return route must already exist as
|
||||
// a catalog EvRoute and is validated server-side as the true
|
||||
// reverse of ev_route_id (EvRoute::isReverseOf, domain.md §2b).
|
||||
'is_round_trip' => ['sometimes', 'boolean'],
|
||||
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
|
||||
'return_ev_route_id' => ['required_if:is_round_trip,true', 'integer', 'exists:ev_routes,id'],
|
||||
'return_departure_time_slot_id' => ['required_if:is_round_trip,true', 'integer', 'exists:departure_time_slots,id'],
|
||||
'return_travel_date' => ['required_if:is_round_trip,true', 'date', 'after_or_equal:travel_date'],
|
||||
'return_selections' => ['required_if:is_round_trip,true', 'array', 'min:1'],
|
||||
'return_selections.*.vehicle_option' => ['required_if:is_round_trip,true', Rule::enum(VehicleOption::class)],
|
||||
'return_selections.*.passenger_count' => ['required_if:is_round_trip,true', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ class BookingResource extends JsonResource
|
||||
'status' => $this->status,
|
||||
'travel_date' => $this->travel_date?->toDateString(),
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'return_travel_date' => $this->return_travel_date?->toDateString(),
|
||||
'is_return_leg' => $this->is_return_leg,
|
||||
'passenger_name' => $this->passenger_name,
|
||||
'passenger_phone' => $this->passenger_phone,
|
||||
'notes' => $this->notes,
|
||||
'pickup_address' => $this->pickup_address,
|
||||
'pickup_lat' => $this->pickup_lat,
|
||||
'pickup_lng' => $this->pickup_lng,
|
||||
@@ -53,6 +54,35 @@ class BookingResource extends JsonResource
|
||||
'label' => $this->timeSlot->label,
|
||||
'time' => $this->timeSlot->time?->format('H:i'),
|
||||
]),
|
||||
// Hand-built, not a nested BookingResource — the linked leg's
|
||||
// own linked_booking points right back here, so nesting the
|
||||
// full resource would recurse forever (domain.md §2b).
|
||||
'linked_booking' => $this->whenLoaded('linkedBooking', fn () => [
|
||||
'id' => $this->linkedBooking->id,
|
||||
'booking_ref' => $this->linkedBooking->booking_ref,
|
||||
'status' => $this->linkedBooking->status,
|
||||
'travel_date' => $this->linkedBooking->travel_date?->toDateString(),
|
||||
'is_return_leg' => $this->linkedBooking->is_return_leg,
|
||||
'route' => $this->linkedBooking->relationLoaded('route') ? [
|
||||
'id' => $this->linkedBooking->route->id,
|
||||
'ev_company_id' => $this->linkedBooking->route->ev_company_id,
|
||||
'from_destination_id' => $this->linkedBooking->route->from_destination_id,
|
||||
'to_destination_id' => $this->linkedBooking->route->to_destination_id,
|
||||
] : null,
|
||||
'time_slot' => $this->linkedBooking->relationLoaded('timeSlot') ? [
|
||||
'id' => $this->linkedBooking->timeSlot->id,
|
||||
'label' => $this->linkedBooking->timeSlot->label,
|
||||
'time' => $this->linkedBooking->timeSlot->time?->format('H:i'),
|
||||
] : null,
|
||||
'vehicle_options' => $this->linkedBooking->relationLoaded('vehicleOptions')
|
||||
? $this->linkedBooking->vehicleOptions->map(fn ($selection) => [
|
||||
'vehicle_option' => $selection->vehicle_option,
|
||||
'passenger_count' => $selection->passenger_count,
|
||||
'unit_price' => $selection->unit_price,
|
||||
'line_total' => $selection->line_total,
|
||||
])
|
||||
: null,
|
||||
]),
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -43,10 +44,14 @@ class Booking extends Model
|
||||
'user_id',
|
||||
'openid',
|
||||
'ev_route_id',
|
||||
'linked_booking_id',
|
||||
'is_return_leg',
|
||||
'departure_time_slot_id',
|
||||
'travel_date',
|
||||
'passenger_name',
|
||||
'passenger_phone',
|
||||
'notes',
|
||||
'remark',
|
||||
'pickup_address',
|
||||
'pickup_lat',
|
||||
'pickup_lng',
|
||||
@@ -55,8 +60,6 @@ class Booking extends Model
|
||||
'dropoff_lng',
|
||||
'price',
|
||||
'status',
|
||||
'is_round_trip',
|
||||
'return_travel_date',
|
||||
'created_by_channel',
|
||||
'driver_name',
|
||||
'driver_phone',
|
||||
@@ -77,8 +80,7 @@ class Booking extends Model
|
||||
'dropoff_lng' => 'decimal:7',
|
||||
'price' => 'decimal:2',
|
||||
'status' => BookingStatus::class,
|
||||
'is_round_trip' => 'boolean',
|
||||
'return_travel_date' => 'date',
|
||||
'is_return_leg' => 'boolean',
|
||||
'created_by_channel' => BookingChannel::class,
|
||||
];
|
||||
}
|
||||
@@ -93,6 +95,16 @@ class Booking extends Model
|
||||
return $this->belongsTo(EvRoute::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The other leg of a round trip (outbound <-> return), linked
|
||||
* bidirectionally by CreateBookingAction. Null for a plain one-way
|
||||
* booking — see the `isRoundTrip()` accessor (domain.md §2b).
|
||||
*/
|
||||
public function linkedBooking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class, 'linked_booking_id');
|
||||
}
|
||||
|
||||
public function timeSlot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
|
||||
@@ -107,4 +119,17 @@ class Booking extends Model
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this booking has a linked leg — i.e. it's one half of a
|
||||
* round trip. Computed, not stored: presence of `linked_booking_id` is
|
||||
* the single source of truth, so it can't drift out of sync the way a
|
||||
* separate flag column could (domain.md §2b).
|
||||
*/
|
||||
public function isRoundTrip(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (): bool => $this->linked_booking_id !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,33 @@ test('created_by_channel is taken from the Device-Type header', function (string
|
||||
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
|
||||
]);
|
||||
|
||||
test('customer-supplied notes are stored and returned', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['notes'] = 'Please call before arriving.';
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', 'Please call before arriving.');
|
||||
|
||||
expect(Booking::first()->notes)->toBe('Please call before arriving.');
|
||||
});
|
||||
|
||||
test('notes is optional and defaults to null', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', null);
|
||||
});
|
||||
|
||||
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
@@ -221,3 +248,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s
|
||||
'admin' => ['admin'],
|
||||
'unrecognized value' => ['smart-fridge'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Same company as $outbound, from/to swapped — the true reverse route.
|
||||
*
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outbound->ev_company_id,
|
||||
'from_destination_id' => $outbound->to_destination_id,
|
||||
'to_destination_id' => $outbound->from_destination_id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
test('round trip: creates two linked bookings, each priced against its own route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.is_round_trip', true)
|
||||
->assertJsonPath('data.is_return_leg', false)
|
||||
->assertJsonPath('data.price', '9000.00')
|
||||
->assertJsonPath('data.linked_booking.is_return_leg', true)
|
||||
->assertJsonPath('data.linked_booking.route.id', $returnRoute->id)
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat')
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00');
|
||||
|
||||
expect(Booking::count())->toBe(2);
|
||||
|
||||
$return = Booking::where('is_return_leg', true)->firstOrFail();
|
||||
expect($return->price)->toEqual('11000.00')
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id);
|
||||
});
|
||||
|
||||
test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $unrelatedRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $unrelatedSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('round trip: return fields are required when is_round_trip is true', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections',
|
||||
]);
|
||||
});
|
||||
|
||||
test('round trip: return_travel_date before travel_date is rejected', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay())
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['return_travel_date']);
|
||||
});
|
||||
|
||||
@@ -325,6 +325,45 @@ test('restoring a deleted booking brings it back', function () {
|
||||
expect(Booking::find($booking->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the remark action is visible for a user with manage_bookings', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('setRemark', $booking);
|
||||
});
|
||||
|
||||
test('the remark action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('setRemark', $booking);
|
||||
});
|
||||
|
||||
test('calling the remark action sets the staff remark on a booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('setRemark', $booking, data: [
|
||||
'remark' => 'Passenger requested a child seat.',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.');
|
||||
});
|
||||
|
||||
test('the remark form is pre-filled with the booking\'s existing remark', function () {
|
||||
$booking = Booking::factory()->create(['remark' => 'Existing remark.']);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->mountTableAction('setRemark', $booking)
|
||||
->assertTableActionDataSet([
|
||||
'remark' => 'Existing remark.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the restore action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
@@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidReturnRouteException;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Exceptions\RoutePricingNotFoundException;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
@@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
|
||||
{
|
||||
return new CreateBookingData(
|
||||
evRouteId: $route->id,
|
||||
@@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti
|
||||
dropoffAddress: '456 Dropoff Ave',
|
||||
createdByChannel: BookingChannel::MiniApp,
|
||||
openid: 'mini-app-openid-123',
|
||||
returnEvRouteId: $roundTrip['route']->id ?? null,
|
||||
returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null,
|
||||
returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null),
|
||||
returnSelections: $roundTrip['selections'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same company as $outbound, from/to swapped — the true reverse route.
|
||||
*
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function makeReverseRoute(EvRoute $outbound, array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outbound->ev_company_id,
|
||||
'from_destination_id' => $outbound->to_destination_id,
|
||||
'to_destination_id' => $outbound->from_destination_id,
|
||||
]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
@@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () {
|
||||
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
||||
});
|
||||
|
||||
test('a plain one-way booking has no linked leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->linked_booking_id)->toBeNull()
|
||||
->and($booking->is_round_trip)->toBeFalse()
|
||||
->and($booking->is_return_leg)->toBeFalse()
|
||||
->and(Booking::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
expect(Booking::count())->toBe(2)
|
||||
->and($outbound->is_return_leg)->toBeFalse()
|
||||
->and($outbound->is_round_trip)->toBeTrue()
|
||||
->and($outbound->price)->toEqual('9000.00');
|
||||
|
||||
$return = $outbound->linkedBooking;
|
||||
|
||||
expect($return)->not->toBeNull()
|
||||
->and($return->is_return_leg)->toBeTrue()
|
||||
->and($return->is_round_trip)->toBeTrue()
|
||||
->and($return->linked_booking_id)->toBe($outbound->id)
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id)
|
||||
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
|
||||
->and($return->price)->toEqual('11000.00');
|
||||
});
|
||||
|
||||
test('a round trip dispatches BookingCreated for both legs', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, 2);
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
|
||||
});
|
||||
|
||||
test('it rejects a return route that is not the reverse of the outbound route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Unrelated route — not from/to swapped.
|
||||
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $unrelatedRoute,
|
||||
'timeSlot' => $unrelatedSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(InvalidReturnRouteException::class);
|
||||
|
||||
// The whole transaction rolls back — no orphan outbound-only booking.
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('return leg selections are validated independently of the outbound leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
// Front seat max per booking is 1 — this should fail validation
|
||||
// for the return leg even though the outbound leg is valid.
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
|
||||
],
|
||||
)))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Return route exists (true reverse) but has no pricing rows at all.
|
||||
$returnRoute = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outboundRoute->ev_company_id,
|
||||
'from_destination_id' => $outboundRoute->to_destination_id,
|
||||
'to_destination_id' => $outboundRoute->from_destination_id,
|
||||
]);
|
||||
$returnSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(RoutePricingNotFoundException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -74,3 +74,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the
|
||||
expect($booking->refresh()->driver_name)->toBe('Daw Hla')
|
||||
->and($booking->car_plate_number)->toBe('YGN-5678');
|
||||
});
|
||||
|
||||
test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () {
|
||||
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$return = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
(new AssignDriverAction)->handle($outbound, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
// Each leg has its own independent driver/vehicle slot — the return leg
|
||||
// can get a completely different (or no-yet-assigned) vehicle, per the
|
||||
// "next available vehicle" business rule (domain.md §2b).
|
||||
expect($outbound->refresh()->driver_name)->toBe('U Aung')
|
||||
->and($return->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user