add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled

This commit is contained in:
Nyan Lin Paing
2026-08-22 21:43:41 +07:00
parent 894352b43f
commit fa908cdcaf
46 changed files with 1679 additions and 182 deletions
@@ -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,
];
}
+29 -4
View File
@@ -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,
);
}
}