Files
shopit-back/app/Domains/Catalog/Services/ExpireStockReservationsService.php

186 lines
6.2 KiB
PHP

<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class ExpireStockReservationsService
{
private const BATCH_SIZE = 500;
public function __construct(
private readonly ReleaseCheckoutService $purchases,
private readonly StockReservationService $reservations,
) {}
/**
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
*/
public function expireOverdue(): array
{
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
do {
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('id', '>', $lastReservationId)
->orderBy('id')
->limit(self::BATCH_SIZE)
->pluck('id');
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
try {
$owner = $this->expireReservation($lastReservationId);
if ($owner !== null) {
$summary[$owner]++;
}
} catch (Throwable $exception) {
$summary['failed']++;
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
'command' => 'reservations:expire',
'stock_reservation_id' => $lastReservationId,
'exception' => $exception,
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $summary['purchases'],
'expired_cart_reservations' => $summary['cart_reservations'],
'expired_orphan_reservations' => $summary['orphan_reservations'],
'failed_reservations' => $summary['failed'],
'total_expired' => $summary['purchases']
+ $summary['cart_reservations']
+ $summary['orphan_reservations'],
]);
return $summary;
}
public function expireIfOverdue(int $reservationId): bool
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
return true;
}
if (! $this->isOverdue($reservation)) {
return false;
}
return $this->expireReservation($reservationId) !== null;
}
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
private function expireReservation(int $reservationId): ?string
{
$purchaseId = Purchase::query()
->where('stock_reservation_id', $reservationId)
->value('id');
if ($purchaseId !== null) {
return $this->expirePurchase((int) $purchaseId);
}
$cartId = Cart::query()
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->value('id');
if ($cartId !== null) {
return $this->expireCart((int) $cartId, $reservationId);
}
return $this->expireOrphan($reservationId);
}
/** @return 'purchases'|null */
private function expirePurchase(int $purchaseId): ?string
{
/** @var Purchase|null $purchase */
$purchase = Purchase::query()->find($purchaseId);
if ($purchase === null) {
return null;
}
$purchase = $this->purchases->expire($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
return 'purchases';
}
$reservation = $purchase->stockReservation;
if ($this->isOverdue($reservation)) {
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
}
return null;
}
/** @return 'cart_reservations'|null */
private function expireCart(int $cartId, int $reservationId): ?string
{
return DB::transaction(function () use ($cartId, $reservationId): ?string {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId)
->where('status', Cart::STATUS_ACTIVE)
->lockForUpdate()
->first();
if ($cart === null) {
return null;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
$cart->update(['status' => Cart::STATUS_EXPIRED]);
return 'cart_reservations';
});
}
/** @return 'orphan_reservations'|null */
private function expireOrphan(int $reservationId): ?string
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
return 'orphan_reservations';
}
private function isOverdue(?StockReservation $reservation): bool
{
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}