initial commit

This commit is contained in:
bastien
2026-06-28 12:29:53 +02:00
parent 03e85d2f9d
commit b2443b654c
220 changed files with 24969 additions and 1 deletions
+238
View File
@@ -0,0 +1,238 @@
<?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;
}
}