refactor(stock): centralize reservation aggregate
This commit is contained in:
parent
8a0f29bdae
commit
bbfdf8f342
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue