refactor(stock): make reservation expiration authoritative
This commit is contained in:
@@ -2,50 +2,169 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
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 CheckoutService $checkout,
|
||||
private readonly ExpireCartReservationsService $carts,
|
||||
private readonly ReleaseCheckoutService $purchases,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{purchases: int, cart_reservations: int}
|
||||
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||
*/
|
||||
public function expireOverdue(): array
|
||||
{
|
||||
$expiredPurchases = null;
|
||||
$expiredCartItems = null;
|
||||
$summary = [
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$lastReservationId = 0;
|
||||
|
||||
try {
|
||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
||||
$expiredCartItems = $this->carts->expireOverdue();
|
||||
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');
|
||||
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_reservations' => $expiredCartItems,
|
||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
||||
]);
|
||||
foreach ($reservationIds as $reservationId) {
|
||||
$lastReservationId = (int) $reservationId;
|
||||
|
||||
return [
|
||||
'purchases' => $expiredPurchases,
|
||||
'cart_reservations' => $expiredCartItems,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('commands')->error('Stock reservation cleanup failed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_reservations' => $expiredCartItems,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
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);
|
||||
|
||||
throw $exception;
|
||||
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;
|
||||
}
|
||||
|
||||
/** @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', '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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +158,12 @@ class StockReservationService
|
||||
});
|
||||
}
|
||||
|
||||
public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation
|
||||
{
|
||||
return DB::transaction(function () use ($cart, $purchase): StockReservation {
|
||||
public function attachToPurchase(
|
||||
Cart $cart,
|
||||
Purchase $purchase,
|
||||
Carbon $expiresAt,
|
||||
): StockReservation {
|
||||
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
/** @var Purchase $lockedPurchase */
|
||||
@@ -185,7 +188,7 @@ class StockReservationService
|
||||
}
|
||||
|
||||
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||
$reservation->update(['expires_at' => $lockedPurchase->expires_at]);
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
$purchase->stock_reservation_id = $reservation->getKey();
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
@@ -299,16 +302,25 @@ class StockReservationService
|
||||
});
|
||||
}
|
||||
|
||||
public function syncPurchaseExpiration(Purchase $purchase): void
|
||||
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
|
||||
{
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
DB::transaction(function () use ($purchase, $expiresAt): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||
}
|
||||
|
||||
StockReservation::query()
|
||||
->whereKey($purchase->stock_reservation_id)
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->update(['expires_at' => $purchase->expires_at]);
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user