239 lines
8.3 KiB
PHP
239 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Exceptions\BookingConflictException;
|
|
use App\Models\Booking;
|
|
use App\Models\Machine;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class BookingService
|
|
{
|
|
public function __construct(
|
|
private readonly WalletService $walletService,
|
|
private readonly PricingService $pricingService,
|
|
private readonly AuditService $auditService,
|
|
) {}
|
|
|
|
public function createBooking(
|
|
User $user,
|
|
Machine $machine,
|
|
Carbon $slotStart,
|
|
Carbon $slotEnd,
|
|
?string $idempotencyKey = null,
|
|
): Booking {
|
|
if ($slotEnd->lte($slotStart)) {
|
|
throw new \InvalidArgumentException('La fin du créneau doit être postérieure au début.');
|
|
}
|
|
|
|
$lockKey = sprintf(
|
|
'booking:machine:%d:%s',
|
|
$machine->id,
|
|
$slotStart->toIso8601String(),
|
|
);
|
|
|
|
return Cache::lock($lockKey, 10)->block(5, function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) {
|
|
return DB::transaction(function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) {
|
|
if ($idempotencyKey !== null) {
|
|
$existing = Booking::query()
|
|
->where('user_id', $user->id)
|
|
->where('machine_id', $machine->id)
|
|
->where('slot_start', $slotStart)
|
|
->where('status', '!=', 'cancelled')
|
|
->first();
|
|
|
|
if ($existing !== null) {
|
|
return $existing;
|
|
}
|
|
}
|
|
|
|
$conflict = Booking::query()
|
|
->where('machine_id', $machine->id)
|
|
->whereIn('status', ['pending', 'confirmed', 'active'])
|
|
->where('slot_start', '<', $slotEnd)
|
|
->where('slot_end', '>', $slotStart)
|
|
->lockForUpdate()
|
|
->exists();
|
|
|
|
if ($conflict) {
|
|
throw new BookingConflictException();
|
|
}
|
|
|
|
$washPrice = $this->pricingService->getActivePrice($machine, $slotStart);
|
|
$bookingFee = (float) config('laverie.booking.fee', 1.00);
|
|
$totalAmount = round($washPrice + $bookingFee, 2);
|
|
|
|
$this->walletService->debit(
|
|
$user,
|
|
$totalAmount,
|
|
Booking::class,
|
|
null,
|
|
$idempotencyKey ? "booking-debit:{$idempotencyKey}" : null,
|
|
['machine_id' => $machine->id, 'slot_start' => $slotStart->toIso8601String()],
|
|
);
|
|
|
|
$booking = Booking::query()->create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'user_id' => $user->id,
|
|
'machine_id' => $machine->id,
|
|
'slot_start' => $slotStart,
|
|
'slot_end' => $slotEnd,
|
|
'booking_fee' => $bookingFee,
|
|
'reserved_amount' => $washPrice,
|
|
'status' => 'confirmed',
|
|
]);
|
|
|
|
if ($machine->status === 'available' && $slotStart->lte(now()) && $slotEnd->gt(now())) {
|
|
$machine->update(['status' => 'reserved']);
|
|
}
|
|
|
|
$this->auditService->log(
|
|
$user,
|
|
'booking.created',
|
|
$booking,
|
|
null,
|
|
$booking->toArray(),
|
|
$machine->establishment?->organization_id,
|
|
$machine->establishment_id,
|
|
);
|
|
|
|
return $booking;
|
|
});
|
|
});
|
|
}
|
|
|
|
public function cancelBooking(Booking $booking, User $user): Booking
|
|
{
|
|
if ($booking->user_id !== $user->id) {
|
|
throw new \RuntimeException('Réservation non autorisée.');
|
|
}
|
|
|
|
if (! $booking->isCancellable()) {
|
|
throw new \RuntimeException('Cette réservation ne peut pas être annulée.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($booking, $user) {
|
|
$booking = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail();
|
|
|
|
$graceHours = (int) config('laverie.booking.cancellation_grace_hours', 2);
|
|
$withinGrace = $booking->slot_start->greaterThan(now()->addHours($graceHours));
|
|
|
|
$penaltyAmount = 0.0;
|
|
$refundAmount = (float) $booking->reserved_amount + (float) $booking->booking_fee;
|
|
|
|
if (! $withinGrace) {
|
|
$penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00);
|
|
$refundAmount = max(0, $refundAmount - $penaltyAmount);
|
|
}
|
|
|
|
if ($refundAmount > 0) {
|
|
$this->walletService->credit(
|
|
$user,
|
|
$refundAmount,
|
|
Booking::class,
|
|
$booking->id,
|
|
"booking-refund:{$booking->uuid}",
|
|
['reason' => $withinGrace ? 'cancellation_full' : 'cancellation_partial'],
|
|
);
|
|
}
|
|
|
|
$booking->update([
|
|
'status' => 'cancelled',
|
|
'cancelled_at' => now(),
|
|
'penalty_amount' => $penaltyAmount,
|
|
'penalty_applied_at' => $penaltyAmount > 0 ? now() : null,
|
|
]);
|
|
|
|
$machine = $booking->machine;
|
|
if ($machine !== null && $machine->status === 'reserved') {
|
|
$machine->update(['status' => 'available']);
|
|
}
|
|
|
|
$this->auditService->log($user, 'booking.cancelled', $booking, null, $booking->fresh()->toArray());
|
|
|
|
return $booking->fresh();
|
|
});
|
|
}
|
|
|
|
public function moveBooking(Booking $booking, User $user, Carbon $newSlotStart, Carbon $newSlotEnd): Booking
|
|
{
|
|
if ($booking->user_id !== $user->id) {
|
|
throw new \RuntimeException('Réservation non autorisée.');
|
|
}
|
|
|
|
if (! in_array($booking->status, ['confirmed', 'pending'], true)) {
|
|
throw new \RuntimeException('Cette réservation ne peut pas être déplacée.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($booking, $user, $newSlotStart, $newSlotEnd) {
|
|
$machine = Machine::query()->findOrFail($booking->machine_id);
|
|
|
|
$this->cancelBooking($booking, $user);
|
|
|
|
return $this->createBooking(
|
|
$user,
|
|
$machine,
|
|
$newSlotStart,
|
|
$newSlotEnd,
|
|
"booking-move:{$booking->uuid}:{$newSlotStart->timestamp}",
|
|
);
|
|
});
|
|
}
|
|
|
|
public function checkNoShows(): int
|
|
{
|
|
$graceMinutes = (int) config('laverie.booking.no_show_grace_minutes', 15);
|
|
$cutoff = now()->subMinutes($graceMinutes);
|
|
$processed = 0;
|
|
|
|
$candidates = Booking::query()
|
|
->with(['user', 'machine', 'washes'])
|
|
->where('status', 'confirmed')
|
|
->where('slot_start', '<=', $cutoff)
|
|
->get();
|
|
|
|
foreach ($candidates as $booking) {
|
|
if ($booking->washes->isNotEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
DB::transaction(function () use ($booking, &$processed) {
|
|
$locked = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail();
|
|
|
|
if ($locked->status !== 'confirmed' || $locked->washes()->exists()) {
|
|
return;
|
|
}
|
|
|
|
$penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00);
|
|
|
|
$locked->update([
|
|
'status' => 'no_show',
|
|
'penalty_amount' => $penaltyAmount,
|
|
'penalty_applied_at' => now(),
|
|
]);
|
|
|
|
$machine = $locked->machine;
|
|
if ($machine !== null && $machine->status === 'reserved') {
|
|
$machine->update(['status' => 'available']);
|
|
}
|
|
|
|
$this->auditService->log(
|
|
$locked->user,
|
|
'booking.no_show',
|
|
$locked,
|
|
null,
|
|
$locked->fresh()->toArray(),
|
|
);
|
|
|
|
$processed++;
|
|
});
|
|
}
|
|
|
|
return $processed;
|
|
}
|
|
}
|