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
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue