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
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
class AuditService
{
public function log(
Model $actor,
string $action,
Model|string $target,
?array $before = null,
?array $after = null,
?int $organizationId = null,
?int $establishmentId = null,
?string $ip = null,
): AuditLog {
[$targetType, $targetId] = $this->resolveTarget($target);
return AuditLog::query()->create([
'actor_type' => $actor->getMorphClass(),
'actor_id' => $actor->getKey(),
'organization_id' => $organizationId,
'establishment_id' => $establishmentId,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'before_data' => $before,
'after_data' => $after,
'ip_address' => $ip,
'created_at' => Carbon::now(),
]);
}
/**
* @return array{0: string, 1: int|null}
*/
private function resolveTarget(Model|string $target): array
{
if ($target instanceof Model) {
return [$target->getMorphClass(), $target->getKey()];
}
return [$target, null];
}
}
+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;
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Services;
use App\Models\Booking;
use App\Models\Machine;
use App\Models\MachineEvent;
use App\Models\Supervisor;
use App\Models\Wash;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class DashboardService
{
/**
* @return array<string, mixed>
*/
public function getKpis(Supervisor $supervisor): array
{
$offlineThresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10);
$today = Carbon::today();
$washQuery = $this->washQuery($supervisor)->whereDate('started_at', $today);
$bookingQuery = $this->bookingQuery($supervisor)->whereDate('slot_start', $today);
$machineQuery = $this->machineQuery($supervisor);
$offlineThreshold = now()->subMinutes($offlineThresholdMinutes);
$offlineCount = (clone $machineQuery)
->where(function ($query) use ($offlineThreshold) {
$query->where('status', 'offline')
->orWhere(function ($query) use ($offlineThreshold) {
$query->where('last_heartbeat_at', '<', $offlineThreshold)
->orWhereNull('last_heartbeat_at');
});
})
->count();
return [
'revenue_today' => (float) (clone $washQuery)
->where('status', 'completed')
->sum('cost'),
'washes_today' => (clone $washQuery)->count(),
'bookings_today' => (clone $bookingQuery)->count(),
'offline_machines' => $offlineCount,
'machines_total' => (clone $machineQuery)->count(),
];
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function getAlerts(Supervisor $supervisor, int $limit = 10): Collection
{
$machineIds = $this->machineQuery($supervisor)->pluck('id');
if ($machineIds->isEmpty()) {
return collect();
}
$errorMachines = Machine::query()
->whereIn('id', $machineIds)
->whereIn('status', ['error', 'maintenance', 'offline'])
->with('establishment:id,name')
->limit($limit)
->get()
->map(fn (Machine $machine) => [
'type' => 'machine_status',
'severity' => $machine->status === 'error' ? 'high' : 'medium',
'message' => sprintf(
'%s (%s) — %s',
$machine->name,
$machine->establishment?->name ?? '—',
$this->machineStatusLabel($machine->status),
),
'occurred_at' => $machine->updated_at?->toIso8601String(),
]);
$recentEvents = MachineEvent::query()
->whereIn('machine_id', $machineIds)
->whereIn('event_type', ['error_reported', 'machine_offline', 'cycle_failed'])
->with('machine:id,name,establishment_id', 'machine.establishment:id,name')
->orderByDesc('occurred_at')
->limit($limit)
->get()
->map(fn (MachineEvent $event) => [
'type' => 'machine_event',
'severity' => $event->event_type === 'error_reported' ? 'high' : 'medium',
'message' => sprintf(
'%s — %s',
$event->machine?->name ?? 'Machine',
$this->eventTypeLabel($event->event_type),
),
'occurred_at' => $event->occurred_at?->toIso8601String(),
]);
return $errorMachines
->concat($recentEvents)
->sortByDesc('occurred_at')
->take($limit)
->values();
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function getMachinesOverview(Supervisor $supervisor): Collection
{
return $this->machineQuery($supervisor)
->with('establishment:id,name')
->orderBy('name')
->limit(12)
->get()
->map(fn (Machine $machine) => [
'uuid' => $machine->uuid,
'name' => $machine->name,
'type' => $machine->type,
'type_label' => $this->machineTypeLabel($machine->type),
'status' => $machine->status,
'status_label' => $this->machineStatusLabel($machine->status),
'establishment_name' => $machine->establishment?->name,
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
]);
}
private function machineQuery(Supervisor $supervisor)
{
$query = Machine::query()->whereHas('establishment', function ($query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
if ($supervisor->establishment_id !== null) {
$query->where('establishment_id', $supervisor->establishment_id);
}
return $query;
}
private function washQuery(Supervisor $supervisor)
{
return Wash::query()->whereHas('machine.establishment', function ($query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
}
private function bookingQuery(Supervisor $supervisor)
{
return Booking::query()->whereHas('machine.establishment', function ($query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
}
private function machineStatusLabel(string $status): string
{
return match ($status) {
'available' => 'Disponible',
'reserved' => 'Réservée',
'running' => 'En cours',
'maintenance' => 'Maintenance',
'offline' => 'Hors ligne',
'error' => 'Erreur',
default => $status,
};
}
private function machineTypeLabel(string $type): string
{
return match ($type) {
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
default => $type,
};
}
private function eventTypeLabel(string $type): string
{
return match ($type) {
'error_reported' => 'Erreur signalée',
'machine_offline' => 'Machine hors ligne',
'cycle_failed' => 'Cycle échoué',
default => $type,
};
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Domain\Integration\MachineCommandResult;
use App\Domain\Integration\MachineStatusSnapshot;
use App\Domain\Integration\MachineProviderInterface;
use App\Domain\Integration\SimulatedMachineProvider;
use App\Models\Machine;
use App\Models\MachineIntegration;
use App\Models\User;
use InvalidArgumentException;
class MachineIntegrationService
{
/** @var array<string, class-string<MachineProviderInterface>> */
private array $providerMap;
public function __construct(
private readonly SimulatedMachineProvider $simulatedProvider,
) {
$this->providerMap = [
config('laverie.simulation.provider_name', 'simulated') => SimulatedMachineProvider::class,
'simulated' => SimulatedMachineProvider::class,
];
}
public function startCycle(Machine $machine, array $payload = [], ?User $user = null): MachineCommandResult
{
if ($user !== null) {
$payload['requested_by_user_id'] = $user->id;
}
return $this->resolveProvider($machine)->startCycle($machine, $payload);
}
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult
{
return $this->resolveProvider($machine)->stopCycle($machine, $payload);
}
public function refreshStatus(Machine $machine): MachineStatusSnapshot
{
return $this->resolveProvider($machine)->refreshStatus($machine);
}
public function handleInboundEvent(string $provider, array $payload): void
{
$this->resolveProviderByName($provider)->handleInboundEvent($payload);
}
private function resolveProvider(Machine $machine): MachineProviderInterface
{
$integration = $machine->relationLoaded('integration')
? $machine->integration
: $machine->integration()->first();
if ($integration === null) {
$integration = MachineIntegration::query()
->where('machine_id', $machine->id)
->where('is_active', true)
->first();
}
$providerName = $integration?->provider ?? config('laverie.simulation.provider_name', 'simulated');
return $this->resolveProviderByName($providerName);
}
private function resolveProviderByName(string $providerName): MachineProviderInterface
{
$class = $this->providerMap[$providerName] ?? null;
if ($class === null) {
throw new InvalidArgumentException("Fournisseur machine inconnu : {$providerName}");
}
return match ($class) {
SimulatedMachineProvider::class => $this->simulatedProvider,
default => app($class),
};
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PaymentService
{
public function __construct(
private readonly WalletService $walletService,
private readonly AuditService $auditService,
) {}
public function initiateTopUp(
User $user,
float $amount,
string $idempotencyKey,
?string $returnUrl = null,
): PaymentTransaction {
if ($amount <= 0) {
throw new \InvalidArgumentException('Le montant doit être strictement positif.');
}
$existing = PaymentTransaction::query()
->where('idempotency_key', $idempotencyKey)
->first();
if ($existing !== null) {
return $existing;
}
$payment = PaymentTransaction::query()->create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'provider' => config('laverie.payment.default_provider', 'simulated'),
'provider_payment_id' => 'sim-'.Str::random(16),
'amount' => $amount,
'currency' => 'EUR',
'status' => 'initiated',
'idempotency_key' => $idempotencyKey,
'return_url' => $returnUrl,
]);
$this->auditService->log($user, 'payment.initiated', $payment, null, $payment->toArray());
return $payment;
}
public function confirmTopUp(string $paymentUuid): PaymentTransaction
{
return DB::transaction(function () use ($paymentUuid) {
$payment = PaymentTransaction::query()
->where('uuid', $paymentUuid)
->lockForUpdate()
->firstOrFail();
if ($payment->status === 'succeeded') {
return $payment;
}
if (! in_array($payment->status, ['initiated', 'pending'], true)) {
throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status}).");
}
$payment->update(['status' => 'succeeded']);
$this->walletService->credit(
$payment->user,
(float) $payment->amount,
PaymentTransaction::class,
$payment->id,
"payment-credit:{$payment->uuid}",
['provider' => $payment->provider],
);
$this->auditService->log(
$payment->user,
'payment.confirmed',
$payment,
null,
$payment->fresh()->toArray(),
);
return $payment->fresh();
});
}
public function handleWebhook(string $provider, array $payload): void
{
$providerPaymentId = $payload['provider_payment_id'] ?? $payload['payment_id'] ?? null;
$idempotencyKey = $payload['idempotency_key'] ?? null;
$status = $payload['status'] ?? 'succeeded';
if ($providerPaymentId === null && $idempotencyKey === null) {
throw new \InvalidArgumentException('Webhook paiement invalide.');
}
DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) {
$query = PaymentTransaction::query()->lockForUpdate();
if ($idempotencyKey !== null) {
$payment = $query->where('idempotency_key', $idempotencyKey)->first();
} else {
$payment = $query
->where('provider', $provider)
->where('provider_payment_id', $providerPaymentId)
->first();
}
if ($payment === null) {
return;
}
if ($payment->status === 'succeeded') {
return;
}
$payment->update([
'raw_payload' => $payload,
]);
if ($status === 'succeeded') {
$this->confirmTopUp($payment->uuid);
} elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) {
$payment->update(['status' => $status]);
}
});
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Services;
use App\Models\Machine;
use App\Models\PricingRule;
use App\Models\Promotion;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class PricingService
{
public function getActivePrice(Machine $machine, ?CarbonInterface $at = null): float
{
$at = Carbon::instance($at ?? now());
$establishment = $machine->establishment;
if ($establishment === null) {
$machine->loadMissing('establishment');
$establishment = $machine->establishment;
}
if ($establishment === null) {
throw new \RuntimeException('Établissement introuvable pour la machine.');
}
$dayType = $this->resolveDayType($at);
$time = $at->format('H:i:s');
$rules = PricingRule::query()
->where('establishment_id', $establishment->id)
->where('is_active', true)
->where(function ($query) use ($machine) {
$query->whereNull('machine_id')
->orWhere('machine_id', $machine->id);
})
->where(function ($query) use ($machine) {
$query->whereNull('machine_type')
->orWhere('machine_type', $machine->type);
})
->where(function ($query) use ($dayType) {
$query->where('day_type', 'all')
->orWhere('day_type', $dayType);
})
->where('slot_start', '<=', $time)
->where('slot_end', '>', $time)
->orderBy('priority')
->orderByRaw('CASE WHEN machine_id IS NOT NULL THEN 0 WHEN machine_type IS NOT NULL THEN 1 ELSE 2 END')
->get();
$rule = $rules->first();
if ($rule === null) {
throw new \RuntimeException('Aucune règle tarifaire active pour cette machine.');
}
$basePrice = (float) $rule->price;
return $this->applyPromotion($establishment->id, $machine->type, $at, $basePrice);
}
private function resolveDayType(CarbonInterface $at): string
{
if ($at->isWeekend()) {
return 'weekend';
}
return 'weekday';
}
private function applyPromotion(int $establishmentId, string $machineType, CarbonInterface $at, float $basePrice): float
{
$promotion = Promotion::query()
->where('establishment_id', $establishmentId)
->where('is_active', true)
->where('starts_at', '<=', $at)
->where('ends_at', '>=', $at)
->where(function ($query) use ($machineType) {
$query->where('machine_type', 'all')
->orWhere('machine_type', $machineType);
})
->orderByDesc('discount_value')
->first();
if ($promotion === null) {
return round($basePrice, 2);
}
$discount = $promotion->discount_type === 'percent'
? $basePrice * ((float) $promotion->discount_value / 100)
: (float) $promotion->discount_value;
return round(max(0, $basePrice - $discount), 2);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services;
use App\Exceptions\WalletInsufficientFundsException;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WalletService
{
public function getBalance(User $user): float
{
$wallet = $this->findOrCreateWallet($user);
return (float) $wallet->current_balance;
}
public function credit(
User $user,
float $amount,
string $sourceType,
?int $sourceId = null,
?string $idempotencyKey = null,
?array $metadata = null,
): WalletTransaction {
return $this->applyMovement($user, 'credit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata);
}
public function debit(
User $user,
float $amount,
string $sourceType,
?int $sourceId = null,
?string $idempotencyKey = null,
?array $metadata = null,
): WalletTransaction {
return $this->applyMovement($user, 'debit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata);
}
private function applyMovement(
User $user,
string $type,
float $amount,
string $sourceType,
?int $sourceId,
?string $idempotencyKey,
?array $metadata,
): WalletTransaction {
if ($amount <= 0) {
throw new \InvalidArgumentException('Le montant doit être strictement positif.');
}
return DB::transaction(function () use ($user, $type, $amount, $sourceType, $sourceId, $idempotencyKey, $metadata) {
if ($idempotencyKey !== null) {
$existing = WalletTransaction::query()
->where('idempotency_key', $idempotencyKey)
->first();
if ($existing !== null) {
return $existing;
}
}
$wallet = Wallet::query()
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($wallet === null) {
$wallet = $this->createWallet($user);
$wallet = Wallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
if (! $wallet->isActive()) {
throw new \RuntimeException('Le porte-monnaie n\'est pas actif.');
}
$balanceBefore = (float) $wallet->current_balance;
if ($type === 'debit' && $balanceBefore < $amount) {
throw new WalletInsufficientFundsException($amount, $balanceBefore);
}
$balanceAfter = $type === 'credit'
? round($balanceBefore + $amount, 2)
: round($balanceBefore - $amount, 2);
$wallet->update(['current_balance' => $balanceAfter]);
return WalletTransaction::query()->create([
'uuid' => (string) Str::uuid(),
'wallet_id' => $wallet->id,
'type' => $type,
'amount' => $amount,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'source_type' => $sourceType,
'source_id' => $sourceId,
'idempotency_key' => $idempotencyKey,
'metadata' => $metadata,
'created_at' => now(),
]);
});
}
private function findOrCreateWallet(User $user): Wallet
{
return Wallet::query()->firstOrCreate(
['user_id' => $user->id],
['currency' => 'EUR', 'current_balance' => 0, 'status' => 'active'],
);
}
private function createWallet(User $user): Wallet
{
return Wallet::query()->create([
'user_id' => $user->id,
'currency' => 'EUR',
'current_balance' => 0,
'status' => 'active',
]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Services;
use App\Exceptions\MachineNotAvailableException;
use App\Models\Booking;
use App\Models\Machine;
use App\Models\User;
use App\Models\Wash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WashService
{
public function __construct(
private readonly WalletService $walletService,
private readonly PricingService $pricingService,
private readonly MachineIntegrationService $machineIntegrationService,
private readonly AuditService $auditService,
) {}
public function startWash(
User $user,
Machine $machine,
string $triggerMethod,
?int $bookingId = null,
?string $program = null,
): Wash {
return DB::transaction(function () use ($user, $machine, $triggerMethod, $bookingId, $program) {
$machine = Machine::query()->whereKey($machine->id)->lockForUpdate()->firstOrFail();
if (! in_array($machine->status, ['available', 'reserved'], true)) {
throw new MachineNotAvailableException("Statut actuel : {$machine->status}");
}
$booking = null;
if ($bookingId !== null) {
$booking = Booking::query()
->whereKey($bookingId)
->where('user_id', $user->id)
->where('machine_id', $machine->id)
->lockForUpdate()
->firstOrFail();
if (! in_array($booking->status, ['confirmed', 'active'], true)) {
throw new \RuntimeException('La réservation n\'est pas valide pour démarrer un lavage.');
}
}
$cost = $booking !== null
? (float) $booking->reserved_amount
: $this->pricingService->getActivePrice($machine);
if ($booking === null) {
$this->walletService->debit(
$user,
$cost,
Wash::class,
null,
'wash-debit:'.Str::uuid(),
['machine_id' => $machine->id],
);
}
$wash = Wash::query()->create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'machine_id' => $machine->id,
'booking_id' => $booking?->id,
'trigger_method' => $triggerMethod,
'status' => 'pending_start',
'program' => $program,
'cost' => $cost,
]);
$result = $this->machineIntegrationService->startCycle($machine, [
'wash_id' => $wash->id,
'wash_uuid' => $wash->uuid,
'program' => $program,
'requested_by_user_id' => $user->id,
], $user);
if (! $result->success) {
throw new \RuntimeException('Échec du démarrage du cycle machine.');
}
$command = $machine->commands()->latest('id')->first();
$wash->update([
'status' => 'running',
'machine_command_id' => $command?->id,
'started_at' => now(),
]);
if ($booking !== null) {
$booking->update(['status' => 'active']);
}
$machine->update(['current_user_id' => $user->id]);
$this->auditService->log(
$user,
'wash.started',
$wash,
null,
$wash->fresh()->toArray(),
$machine->establishment?->organization_id,
$machine->establishment_id,
);
return $wash->fresh(['machine', 'booking']);
});
}
}