Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)

- Booking model with booking_vehicle_options line items (supports mixing
  vehicle options like front_seat + back_seat in one booking), price
  snapshot, status machine, and driver/car assignment fields
- BookingService: front-seat max, disabled-option toggles, duplicate-option
  and whole-vehicle-exclusivity guards
- CreateBookingAction, CancelBookingAction, AssignDriverAction
- BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock
- POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy)
- BookingPlugin + Filament BookingResource: list, detail view, Cancel and
  Assign Driver actions (shared between table and detail page)
- domain.md updated for multi-vehicle-option bookings (§2) and driver/
  vehicle assignment (§5a)
This commit is contained in:
Nyan Lin Paing
2026-08-08 21:43:15 +07:00
parent 4da9ecfe7d
commit 5b68f4fa38
51 changed files with 2698 additions and 42 deletions
@@ -0,0 +1,53 @@
<?php
namespace Modules\Booking\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
/**
* @extends Factory<Booking>
*/
class BookingFactory extends Factory
{
protected $model = Booking::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'booking_ref' => 'EVB-'.strtoupper(Str::random(6)),
'user_id' => null,
'openid' => null,
'ev_route_id' => EvRoute::factory(),
'departure_time_slot_id' => DepartureTimeSlot::factory(),
'travel_date' => now()->addDay()->toDateString(),
'passenger_name' => $this->faker->name(),
'passenger_phone' => $this->faker->phoneNumber(),
'pickup_address' => $this->faker->address(),
'pickup_lat' => null,
'pickup_lng' => null,
'dropoff_address' => $this->faker->address(),
'dropoff_lat' => null,
'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,
'car_plate_number' => null,
'car_model' => null,
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Booking\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Booking\Models\Booking;
use Modules\Booking\Models\BookingVehicleOption;
use Modules\Shared\Enums\VehicleOption;
/**
* @extends Factory<BookingVehicleOption>
*/
class BookingVehicleOptionFactory extends Factory
{
protected $model = BookingVehicleOption::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'booking_id' => Booking::factory(),
'vehicle_option' => VehicleOption::BackSeat,
'passenger_count' => 1,
'unit_price' => 9000.00,
'line_total' => 9000.00,
];
}
}
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->string('booking_ref')->unique();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('openid')->nullable()->index();
$table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete();
$table->foreignId('departure_time_slot_id')->constrained('departure_time_slots')->cascadeOnDelete();
$table->date('travel_date');
$table->string('passenger_name');
$table->string('passenger_phone');
$table->string('pickup_address');
$table->decimal('pickup_lat', 10, 7)->nullable();
$table->decimal('pickup_lng', 10, 7)->nullable();
$table->string('dropoff_address');
$table->decimal('dropoff_lat', 10, 7)->nullable();
$table->decimal('dropoff_lng', 10, 7)->nullable();
// Total across all booking_vehicle_options lines — see that table for the
// per-vehicle-option breakdown (a booking can mix e.g. front_seat + back_seat).
$table->decimal('price', 10, 2);
$table->string('status')->default('pending_payment');
$table->boolean('is_round_trip')->default(false);
$table->date('return_travel_date')->nullable();
$table->string('created_by_channel');
$table->timestamps();
$table->index(['ev_route_id', 'travel_date', 'departure_time_slot_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One row per Vehicle Option selected on a booking a booking can mix
* e.g. front_seat + back_seat in one go (domain.md §2). The unique
* constraint keeps each option to a single line per booking (no
* duplicate front_seat rows); BookingService still enforces the
* business rules (front-seat max, disabled options, whole-vehicle
* exclusivity) on top of this shape.
*
* Also the natural source table for the deferred real-capacity-check
* phase (domain.md §7): summing passenger_count per vehicle_option for
* a route/date/time-slot is exactly what that future check needs.
*/
public function up(): void
{
Schema::create('booking_vehicle_options', function (Blueprint $table) {
$table->id();
$table->foreignId('booking_id')->constrained('bookings')->cascadeOnDelete();
$table->string('vehicle_option');
$table->unsignedInteger('passenger_count')->default(1);
// Snapshotted at booking time, same as bookings.price — never re-read
// from route_pricing later (domain.md §3).
$table->decimal('unit_price', 10, 2);
$table->decimal('line_total', 10, 2);
$table->timestamps();
$table->unique(['booking_id', 'vehicle_option']);
$table->index('vehicle_option');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('booking_vehicle_options');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Driver/vehicle details, filled in by admin staff once a booking is
* confirmed (paid) and dispatch assigns who's actually doing the trip.
* Nullable unknown until assignment happens, and never required for
* pending_payment/cancelled/expired bookings.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->string('driver_name')->nullable();
$table->string('driver_phone')->nullable();
$table->string('car_plate_number')->nullable();
$table->string('car_model')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']);
});
}
};