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
@@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Modules\Shared\Enums\VehicleOption;
@@ -15,74 +17,88 @@ class EvRouteForm
{
return $schema
->components([
Select::make('ev_company_id')
->label('EV Company')
->relationship('company', 'name')
->required()
->searchable()
->preload(),
Select::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->required()
->searchable()
->preload(),
Select::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->required()
->searchable()
->preload()
->different('from_destination_id')
->validationMessages([
'different' => 'The destination must be different from the origin.',
]),
Select::make('timeSlots')
->label('Departure Time Slots')
->relationship('timeSlots', 'label')
->multiple()
->searchable()
->preload(),
Toggle::make('is_round_trip')
->required()
->default(false),
Toggle::make('is_active')
->required()
->default(false)
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
Repeater::make('pricing')
->relationship()
->label('Pricing')
Section::make('Route')
->schema([
Select::make('vehicle_option')
->options(array_combine(
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
))
->disabled()
->dehydrated()
->required(),
TextInput::make('price')
->numeric()
->minValue(0)
->required(),
Toggle::make('is_blocked')
->label('Blocked')
->helperText('Hidden from booking regardless of price.'),
Grid::make(2)
->schema([
Select::make('ev_company_id')
->label('EV Company')
->relationship('company', 'name')
->required()
->searchable()
->preload(),
Select::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->required()
->searchable()
->preload(),
Select::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->required()
->searchable()
->preload()
->different('from_destination_id')
->validationMessages([
'different' => 'The destination must be different from the origin.',
]),
Select::make('timeSlots')
->label('Departure Time Slots')
->relationship('timeSlots', 'label')
->multiple()
->searchable()
->preload(),
]),
])
->columns(3)
->default(
collect(VehicleOption::cases())
->map(fn (VehicleOption $option) => [
'vehicle_option' => $option->value,
'price' => 0,
'is_blocked' => false,
->columnSpanFull(),
Section::make('Options')
->schema([
Grid::make(2)
->schema([
Toggle::make('is_active')
->required()
->default(false)
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
]),
])
->columnSpanFull(),
Section::make('Pricing')
->schema([
Repeater::make('pricing')
->relationship()
->hiddenLabel()
->schema([
Select::make('vehicle_option')
->options(array_combine(
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
))
->disabled()
->dehydrated()
->required(),
TextInput::make('price')
->numeric()
->minValue(0)
->required(),
Toggle::make('is_blocked')
->label('Blocked')
->helperText('Hidden from booking regardless of price.'),
])
->all()
)
->addable(false)
->deletable(false)
->reorderable(false)
->columns(3)
->default(
collect(VehicleOption::cases())
->map(fn (VehicleOption $option) => [
'vehicle_option' => $option->value,
'price' => 0,
'is_blocked' => false,
])
->all()
)
->addable(false)
->deletable(false)
->reorderable(false),
])
->columnSpanFull(),
]);
}
@@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Support\Enums\Width;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Modules\Routing\Models\EvRoute;
@@ -40,8 +42,6 @@ class EvRoutesTable
.($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0)))
->all())
->listWithLineBreaks(),
IconColumn::make('is_round_trip')
->boolean(),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
@@ -50,9 +50,25 @@ class EvRoutesTable
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('ev_company_id')
->label('Company')
->relationship('company', 'name')
->searchable()
->preload(),
SelectFilter::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->searchable()
->preload(),
SelectFilter::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->searchable()
->preload(),
TernaryFilter::make('is_active'),
TernaryFilter::make('is_round_trip'),
])
->filtersFormColumns(2)
->filtersFormWidth(Width::Large)
->recordActions([
EditAction::make(),
])
@@ -2,10 +2,16 @@
namespace Modules\Routing\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Http\Requests\SearchRoutesRequest;
use Modules\Routing\Http\Resources\EvRouteResource;
use Modules\Routing\Http\Resources\RoutePricingResource;
use Modules\Routing\Http\Resources\RouteTimeSlotResource;
@@ -22,26 +28,111 @@ class EvRouteController extends Controller
private const CACHE_TTL_MINUTES = 5;
public function index(Request $request): AnonymousResourceCollection
/**
* POST, not GET: round_trip=true returns two independent result sets
* (routes + return_routes) in one response, which doesn't fit a plain
* GET-with-query-params search shape as cleanly (domain.md §2b).
*/
public function search(SearchRoutesRequest $request): JsonResponse
{
$filters = $request->only(['company', 'from', 'to', 'date']);
$page = $request->integer('page', 1);
$filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']);
$isRoundTrip = $request->boolean('round_trip');
$routes = Cache::tags(self::CACHE_TAG)->remember(
'routes:index:'.md5(json_encode($filters + ['page' => $page])),
// Separate page params: routes and return_routes almost always have
// different totals, so paging one must never slice the other at the
// same offset (e.g. return_routes with only 3 rows would come back
// empty on page=2 while routes still has real data there).
$routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1));
$returnRoutes = $isRoundTrip
? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1))
: new LengthAwarePaginator([], 0, 15);
return response()->json([
'routes' => EvRouteResource::collection($routes)->response()->getData(true),
'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true),
// The company/time_slot options actually available for this
// from->to pair — computed from from/to alone, ignoring any
// company/time_slot already applied, so the client can offer
// switching between them rather than guessing a static list.
'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null),
'return_filters' => $isRoundTrip
? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null)
: ['companies' => [], 'time_slots' => []],
]);
}
/**
* @return array{companies: array<int, array<string, mixed>>, time_slots: array<int, array<string, mixed>>}
*/
private function filterOptions(mixed $from, mixed $to): array
{
if (blank($from) || blank($to)) {
return ['companies' => [], 'time_slots' => []];
}
$cacheKey = "routes:filter-options:{$from}:{$to}";
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES),
function () use ($from, $to) {
$routes = EvRoute::query()
->where('is_active', true)
->where('from_destination_id', $from)
->where('to_destination_id', $to)
->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)])
->get();
$companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values();
$timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values();
return [
// Facet purposes only — not the full EvCompanyResource
// (no slug/description/contact/logo needed just to
// populate a filter option).
'companies' => $companies->map(fn (EvCompany $company) => [
'id' => $company->id,
'name' => $company->name,
'mm_name' => $company->mm_name,
])->all(),
'time_slots' => $timeSlots->map(fn ($slot) => [
'id' => $slot->id,
'label' => $slot->label,
'time' => $slot->time?->format('H:i'),
])->all(),
];
},
);
}
/**
* @param array<string, mixed> $filters Keyed by 'from'/'to' regardless of
* $fromKey/$toKey swapped for the return leg of a round trip.
*/
private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator
{
$cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page]));
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => EvRoute::query()
->where('is_active', true)
->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company')))
->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from')))
->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to')))
->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company']))
->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey]))
->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey]))
->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas(
'timeSlots',
fn (Builder $timeSlotQuery) => $timeSlotQuery
->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s'))
->where('ev_route_time_slots.is_active', true),
))
// `date` is accepted for forward-compatibility with future per-date capacity
// checks (domain.md §7), but v1 has no route-level calendar to filter against.
->with(self::EAGER_LOADS)
->paginate(),
->paginate(perPage: 15, pageName: $pageName, page: $page),
);
return EvRouteResource::collection($routes);
}
public function show(EvRoute $route): EvRouteResource
@@ -0,0 +1,41 @@
<?php
namespace Modules\Routing\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Shape validation only for the routes search endpoint. round_trip=true
* requires both from and to "return" only means something for a specific
* origin/destination pair, not an unfiltered route list.
*/
class SearchRoutesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'company' => ['nullable', 'integer', 'exists:ev_companies,id'],
'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'],
'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'],
'date' => ['nullable', 'date'],
// The catalog's shared time value (e.g. "06:00"), not a
// DepartureTimeSlot id — matches how customers think about
// departure times (domain.md §1).
'time_slot' => ['nullable', 'date_format:H:i'],
'round_trip' => ['sometimes', 'boolean'],
// Independent page cursors — routes and return_routes almost
// always have different totals, so they can't share one `page`
// without one side silently paginating the other's offset.
'page' => ['nullable', 'integer', 'min:1'],
'return_page' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource
{
return [
'id' => $this->id,
'is_round_trip' => $this->is_round_trip,
'is_active' => $this->is_active,
'company' => new EvCompanyResource($this->whenLoaded('company')),
'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')),
+12 -2
View File
@@ -42,7 +42,6 @@ class EvRoute extends Model
'ev_company_id',
'from_destination_id',
'to_destination_id',
'is_round_trip',
'is_active',
];
@@ -52,7 +51,6 @@ class EvRoute extends Model
protected function casts(): array
{
return [
'is_round_trip' => 'boolean',
'is_active' => 'boolean',
];
}
@@ -99,4 +97,16 @@ class EvRoute extends Model
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
);
}
/**
* True when this route is the exact reverse direction of $other (from
* and to swapped) used to validate a booking's `return_ev_route_id`
* is genuinely the return leg of its outbound route, not an unrelated
* pair (domain.md §2b).
*/
public function isReverseOf(EvRoute $other): bool
{
return $this->from_destination_id === $other->to_destination_id
&& $this->to_destination_id === $other->from_destination_id;
}
}