refactor(stock): centralize reservation aggregate

This commit is contained in:
ncoronel 2026-08-25 15:05:23 -03:00
parent 8a0f29bdae
commit bbfdf8f342
6 changed files with 64 additions and 142 deletions

View File

@ -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 public function expireOverdue(): array
{ {
@ -43,24 +43,24 @@ class ExpireStockReservationsService
->limit(self::BATCH_SIZE) ->limit(self::BATCH_SIZE)
->pluck('id'); ->pluck('id');
foreach ($reservationIds as $reservationId) { Log::channel('commands')->info('Stock reservation cleanup completed.', [
$lastReservationId = (int) $reservationId; 'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_reservations' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
try { return [
$owner = $this->expireReservation($lastReservationId); 'purchases' => $expiredPurchases,
if ($owner !== null) { 'cart_reservations' => $expiredCartItems,
$summary[$owner]++; ];
} } catch (Throwable $exception) {
} catch (Throwable $exception) { Log::channel('commands')->error('Stock reservation cleanup failed.', [
$summary['failed']++; 'command' => 'reservations:expire',
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [ 'expired_purchases' => $expiredPurchases,
'command' => 'reservations:expire', 'expired_cart_reservations' => $expiredCartItems,
'stock_reservation_id' => $lastReservationId, 'exception' => $exception,
'exception' => $exception, ]);
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
Log::channel('commands')->info('Stock reservation cleanup completed.', [ Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire', 'command' => 'reservations:expire',

View File

@ -4,7 +4,6 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\StockReservationLine;
@ -44,8 +43,13 @@ class StockReservationService
? null ? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
if ($reservation !== null) { if ($reservation !== null
$this->assertUsableCartReservation($reservation); && $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 === []) { if ($requirements === []) {
@ -63,7 +67,7 @@ class StockReservationService
return null; return null;
} }
if ($reservation === null) { if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
$reservation = StockReservation::query()->create([ $reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE, 'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(), 'expires_at' => $this->expiration(),
@ -154,12 +158,9 @@ class StockReservationService
}); });
} }
public function attachToPurchase( public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation
Cart $cart, {
Purchase $purchase, return DB::transaction(function () use ($cart, $purchase): StockReservation {
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
/** @var Cart $lockedCart */ /** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */ /** @var Purchase $lockedPurchase */
@ -171,7 +172,9 @@ class StockReservationService
/** @var StockReservation $reservation */ /** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id); $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() $linkedPurchase = Purchase::query()
->where('stock_reservation_id', $reservation->getKey()) ->where('stock_reservation_id', $reservation->getKey())
@ -182,7 +185,7 @@ class StockReservationService
} }
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); $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(); $purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey(); $cart->current_stock_reservation_id = $reservation->getKey();
@ -207,9 +210,6 @@ class StockReservationService
if ($reservation->status !== StockReservation::STATUS_ACTIVE) { if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.'); 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); $lines = $this->lockLines($reservation);
if ($lines->isEmpty()) { 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( public function releaseCurrentCartReservation(
Cart $cart, Cart $cart,
string $reason = self::REASON_CART_CHANGED, 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 { if ($purchase->stock_reservation_id === null) {
/** @var Purchase $purchase */ return;
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); }
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
/** @var StockReservation $reservation */ StockReservation::query()
$reservation = StockReservation::query() ->whereKey($purchase->stock_reservation_id)
->lockForUpdate() ->where('status', StockReservation::STATUS_ACTIVE)
->findOrFail($purchase->stock_reservation_id); ->update(['expires_at' => $purchase->expires_at]);
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]);
});
} }
/** /**
@ -464,24 +408,9 @@ class StockReservationService
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null, 'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
]); ]);
if ($status === StockReservation::STATUS_RELEASED) { Cart::query()
Cart::query() ->where('current_stock_reservation_id', $reservation->getKey())
->where('current_stock_reservation_id', $reservation->getKey()) ->update(['current_stock_reservation_id' => null]);
->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.');
}
} }
private function expiration(): Carbon private function expiration(): Carbon

View File

@ -394,6 +394,7 @@ class InvitationPurchaseProvisioner
if ($reservationId === null) { if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([ $reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed', 'status' => 'committed',
'expires_at' => null,
'committed_at' => $now, 'committed_at' => $now,
'released_at' => null, 'released_at' => null,
'expired_at' => null, 'expired_at' => null,

View File

@ -102,11 +102,9 @@ class ReleaseCheckoutService
}); });
} }
private function releasePurchaseReservations( private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
Purchase $purchase, {
string $targetStatus, $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
?Cart $cart,
): void {
try { try {
$this->reservations->releaseForPurchase( $this->reservations->releaseForPurchase(
$purchase, $purchase,
@ -129,35 +127,23 @@ class ReleaseCheckoutService
return; return;
} }
if ($targetStatus === Purchase::STATUS_EXPIRED if ($cart->status === 'active') {
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
Cart::query() Cart::query()
->whereKey($cart->getKey()) ->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey()) ->where('current_purchase_id', $purchase->getKey())
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
->update([ ->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null, 'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]); ]);
return; if ($targetStatus === Purchase::STATUS_CANCELLED) {
} $this->reservations->syncCart($cart);
}
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);
return; return;
} }
if ($cart->status !== Cart::STATUS_CHECKOUT) { if ($cart->status !== 'checkout') {
return; return;
} }

View File

@ -206,7 +206,7 @@ class StartCheckoutService
$cart->getKey(), $cart->getKey(),
); );
$cart->update(['current_purchase_id' => $purchase->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(); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems); $this->loadCartItems($cartItems);
@ -272,7 +272,7 @@ class StartCheckoutService
$cart->getKey(), $cart->getKey(),
); );
$cart->update(['current_purchase_id' => $purchase->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)); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
@ -319,6 +319,14 @@ class StartCheckoutService
$currentPurchase->update([ $currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED, '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; return $cart;

View File

@ -16,8 +16,6 @@ Artisan::command('reservations:expire', function (): void {
$this->info("Expired purchases: {$expired['purchases']}"); $this->info("Expired purchases: {$expired['purchases']}");
$this->info("Expired cart reservations: {$expired['cart_reservations']}"); $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'); })->purpose('Release expired stock reservations from purchases and abandoned carts');
Schedule::command('reservations:expire') Schedule::command('reservations:expire')