diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index 58bf5a8..dbce34b 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -21,7 +21,7 @@ class ExpireStockReservationsService ) {} /** - * @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int} + * @return array{purchases: int, cart_reservations: int} */ public function expireOverdue(): array { @@ -43,24 +43,24 @@ class ExpireStockReservationsService ->limit(self::BATCH_SIZE) ->pluck('id'); - foreach ($reservationIds as $reservationId) { - $lastReservationId = (int) $reservationId; + Log::channel('commands')->info('Stock reservation cleanup completed.', [ + 'command' => 'reservations:expire', + 'expired_purchases' => $expiredPurchases, + 'expired_cart_reservations' => $expiredCartItems, + 'total_expired' => $expiredPurchases + $expiredCartItems, + ]); - 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); + 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, + ]); Log::channel('commands')->info('Stock reservation cleanup completed.', [ 'command' => 'reservations:expire', diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index c52f4ea..8215302 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -4,7 +4,6 @@ namespace App\Domains\Catalog\Services; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; @@ -44,8 +43,13 @@ class StockReservationService ? null : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); - if ($reservation !== null) { - $this->assertUsableCartReservation($reservation); + if ($reservation !== null + && $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && $reservation->expires_at->isPast()) { + $this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null); + $lockedCart->update(['current_stock_reservation_id' => null]); + $reservation = null; } if ($requirements === []) { @@ -63,7 +67,7 @@ class StockReservationService return null; } - if ($reservation === null) { + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) { $reservation = StockReservation::query()->create([ 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => $this->expiration(), @@ -154,12 +158,9 @@ class StockReservationService }); } - public function attachToPurchase( - Cart $cart, - Purchase $purchase, - Carbon $expiresAt, - ): StockReservation { - return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation { + public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation + { + return DB::transaction(function () use ($cart, $purchase): StockReservation { /** @var Cart $lockedCart */ $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); /** @var Purchase $lockedPurchase */ @@ -171,7 +172,9 @@ class StockReservationService /** @var StockReservation $reservation */ $reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id); - $this->assertUsableCartReservation($reservation); + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + throw new \InvalidArgumentException('La reserva de stock no está activa.'); + } $linkedPurchase = Purchase::query() ->where('stock_reservation_id', $reservation->getKey()) @@ -182,7 +185,7 @@ class StockReservationService } $lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); - $reservation->update(['expires_at' => $expiresAt]); + $reservation->update(['expires_at' => $lockedPurchase->expires_at]); $purchase->stock_reservation_id = $reservation->getKey(); $cart->current_stock_reservation_id = $reservation->getKey(); @@ -207,9 +210,6 @@ class StockReservationService if ($reservation->status !== StockReservation::STATUS_ACTIVE) { throw new \InvalidArgumentException('La reserva de stock no está activa.'); } - if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { - throw new StockReservationExpiredException; - } $lines = $this->lockLines($reservation); if ($lines->isEmpty()) { @@ -264,50 +264,6 @@ class StockReservationService }); } - public function returnToCart(Purchase $purchase, Cart $cart): StockReservation - { - return DB::transaction(function () use ($purchase, $cart): StockReservation { - /** @var Purchase $purchase */ - $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); - /** @var Cart $cart */ - $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); - - if ($purchase->stock_reservation_id === null - || $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) { - throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.'); - } - - /** @var StockReservation $reservation */ - $reservation = StockReservation::query() - ->lockForUpdate() - ->findOrFail($purchase->stock_reservation_id); - $this->assertUsableCartReservation($reservation); - - $purchase->update(['stock_reservation_id' => null]); - $cart->update(['current_purchase_id' => null]); - $reservation->update(['expires_at' => $this->expiration()]); - - return $reservation->fresh('lines'); - }); - } - - public function assertCartReservationUsable(Cart $cart): void - { - DB::transaction(function () use ($cart): void { - /** @var Cart $cart */ - $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); - if ($cart->current_stock_reservation_id === null) { - return; - } - - /** @var StockReservation $reservation */ - $reservation = StockReservation::query() - ->lockForUpdate() - ->findOrFail($cart->current_stock_reservation_id); - $this->assertUsableCartReservation($reservation); - }); - } - public function releaseCurrentCartReservation( Cart $cart, string $reason = self::REASON_CART_CHANGED, @@ -343,28 +299,16 @@ class StockReservationService }); } - public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void + public function syncPurchaseExpiration(Purchase $purchase): void { - 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.'); - } + if ($purchase->stock_reservation_id === null) { + return; + } - /** @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.'); - } - if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { - throw new StockReservationExpiredException; - } - - $reservation->update(['expires_at' => $expiresAt]); - }); + StockReservation::query() + ->whereKey($purchase->stock_reservation_id) + ->where('status', StockReservation::STATUS_ACTIVE) + ->update(['expires_at' => $purchase->expires_at]); } /** @@ -464,24 +408,9 @@ class StockReservationService 'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null, ]); - if ($status === StockReservation::STATUS_RELEASED) { - Cart::query() - ->where('current_stock_reservation_id', $reservation->getKey()) - ->update(['current_stock_reservation_id' => null]); - } - } - - private function assertUsableCartReservation(StockReservation $reservation): void - { - if ($reservation->status === StockReservation::STATUS_EXPIRED - || ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) { - throw new StockReservationExpiredException; - } - - if ($reservation->status !== StockReservation::STATUS_ACTIVE - || $reservation->expires_at === null) { - throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.'); - } + Cart::query() + ->where('current_stock_reservation_id', $reservation->getKey()) + ->update(['current_stock_reservation_id' => null]); } private function expiration(): Carbon diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php index 19341b8..632e69b 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -394,6 +394,7 @@ class InvitationPurchaseProvisioner if ($reservationId === null) { $reservationId = DB::table('stock_reservations')->insertGetId([ 'status' => 'committed', + 'expires_at' => null, 'committed_at' => $now, 'released_at' => null, 'expired_at' => null, diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index a63ef14..41274fa 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -102,11 +102,9 @@ class ReleaseCheckoutService }); } - private function releasePurchaseReservations( - Purchase $purchase, - string $targetStatus, - ?Cart $cart, - ): void { + private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void + { + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); try { $this->reservations->releaseForPurchase( $purchase, @@ -129,35 +127,23 @@ class ReleaseCheckoutService return; } - if ($targetStatus === Purchase::STATUS_EXPIRED - && in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) { + if ($cart->status === 'active') { Cart::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $purchase->getKey()) - ->where('current_stock_reservation_id', $purchase->stock_reservation_id) ->update([ - 'status' => Cart::STATUS_EXPIRED, 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, ]); - return; - } - - if ($cart->status === Cart::STATUS_ACTIVE) { - $cartUpdate = [ - 'current_purchase_id' => null, - 'current_stock_reservation_id' => null, - ]; - - Cart::query() - ->whereKey($cart->getKey()) - ->where('current_purchase_id', $purchase->getKey()) - ->update($cartUpdate); + if ($targetStatus === Purchase::STATUS_CANCELLED) { + $this->reservations->syncCart($cart); + } return; } - if ($cart->status !== Cart::STATUS_CHECKOUT) { + if ($cart->status !== 'checkout') { return; } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b34fd41..6fbf4e7 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -206,7 +206,7 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); - $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); + $this->reservations->attachToPurchase($cart, $purchase); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); $this->loadCartItems($cartItems); @@ -272,7 +272,7 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); - $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); + $this->reservations->attachToPurchase($cart, $purchase); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); return $this->loadPurchase($purchase); @@ -319,6 +319,14 @@ class StartCheckoutService $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, ]); + $this->reservations->releaseForPurchase( + $currentPurchase, + reason: StockReservationService::REASON_PURCHASE_SUPERSEDED, + ); + $cart->update([ + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]); } return $cart; diff --git a/routes/console.php b/routes/console.php index e9597ab..cc8bdf4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -16,8 +16,6 @@ Artisan::command('reservations:expire', function (): void { $this->info("Expired purchases: {$expired['purchases']}"); $this->info("Expired cart reservations: {$expired['cart_reservations']}"); - $this->info("Expired orphan reservations: {$expired['orphan_reservations']}"); - $this->info("Failed reservations: {$expired['failed']}"); })->purpose('Release expired stock reservations from purchases and abandoned carts'); Schedule::command('reservations:expire')