From 24bfef431baa8d257ac86140c29fcf383f01347c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 15:05:23 -0300 Subject: [PATCH] refactor(stock): centralize reservation aggregate --- app/Domains/Cart/Models/Cart.php | 46 +- app/Domains/Cart/Models/CartItem.php | 8 - .../ExpireCartReservationsService.php | 117 ++-- app/Domains/Catalog/Models/Inventory.php | 6 +- .../Catalog/Models/StockReservation.php | 34 +- .../Catalog/Models/StockReservationLine.php | 38 ++ .../Services/CatalogInventoryService.php | 18 + .../ExpireStockReservationsService.php | 8 +- .../Services/StockReservationService.php | 547 +++++++++++------- .../InvitationPurchaseProvisioner.php | 27 +- app/Domains/Purchase/Models/Purchase.php | 8 +- .../Checkout/CompleteCheckoutService.php | 15 +- .../Checkout/ReleaseCheckoutService.php | 59 +- .../Checkout/StartCheckoutService.php | 57 +- .../TenantTransactionResetService.php | 24 +- .../Services/UserPurchaseLimitService.php | 8 +- ...5_020000_centralize_stock_reservations.php | 180 ++++++ routes/console.php | 2 +- 18 files changed, 783 insertions(+), 419 deletions(-) create mode 100644 app/Domains/Catalog/Models/StockReservationLine.php create mode 100644 database/migrations/2026_08_25_020000_centralize_stock_reservations.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 705036f..749b500 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\StockReservationService; @@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 'status', 'origin', 'current_purchase_id', + 'current_stock_reservation_id', ])] class Cart extends Model { @@ -45,6 +47,7 @@ class Cart extends Model return [ 'user_id' => 'integer', 'current_purchase_id' => 'integer', + 'current_stock_reservation_id' => 'integer', ]; } @@ -84,6 +87,15 @@ class Cart extends Model return $this->belongsTo(Purchase::class, 'current_purchase_id'); } + /** @return BelongsTo */ + public function currentStockReservation(): BelongsTo + { + return $this->belongsTo( + StockReservation::class, + 'current_stock_reservation_id', + ); + } + public function getTotalAmount(): float { $items = $this->relationLoaded('items') @@ -140,12 +152,11 @@ class Cart extends Model 'cantidad' => $quantity, ]); } else { - app(StockReservationService::class)->ensure($item, $selectedItem); $item->cantidad += $quantity; $item->save(); } - app(StockReservationService::class)->reserve($item, $selectedItem, $quantity); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); }); @@ -205,7 +216,6 @@ class Cart extends Model $nextAvailableQuantity, ); - app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad); $availableQuantity = $inventoryService->availableQuantity($nextSelection); if ($availableQuantity !== null && $availableQuantity < $quantity) { @@ -222,11 +232,10 @@ class Cart extends Model ->first(); if ($targetItem !== null) { - app(StockReservationService::class)->ensure($targetItem, $nextSelection); $targetItem->cantidad += $quantity; $targetItem->save(); - app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity); $item->delete(); + app(StockReservationService::class)->syncCart($this); return $targetItem->fresh(); } @@ -234,7 +243,7 @@ class Cart extends Model $item->variant_id = $variantId; $item->cantidad = $quantity; $item->save(); - app(StockReservationService::class)->reserve($item, $nextSelection, $quantity); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); } @@ -263,16 +272,9 @@ class Cart extends Model ]); } - if ($delta > 0) { - app(StockReservationService::class)->reserve($item, $currentSelection, $delta); - } - - if ($delta < 0) { - app(StockReservationService::class)->release($item, $currentSelection, abs($delta)); - } - $item->cantidad = $quantity; $item->save(); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); }); @@ -289,17 +291,8 @@ class Cart extends Model ->lockForUpdate() ->firstOrFail(); - $selectedItem = $this->resolveScopedItem( - $item->catalog_item_id, - $item->variant_id, - true, - ); - app(StockReservationService::class)->release( - $item, - $selectedItem, - $item->cantidad, - ); $item->delete(); + app(StockReservationService::class)->syncCart($this); }); } @@ -347,7 +340,10 @@ class Cart extends Model ]); } - app(StockReservationService::class)->detachFromPurchase($currentPurchase); + app(StockReservationService::class)->releaseForPurchase( + $currentPurchase, + reason: StockReservationService::REASON_PURCHASE_SUPERSEDED, + ); self::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $currentPurchase->getKey()) diff --git a/app/Domains/Cart/Models/CartItem.php b/app/Domains/Cart/Models/CartItem.php index 8ece5b4..3841533 100644 --- a/app/Domains/Cart/Models/CartItem.php +++ b/app/Domains/Cart/Models/CartItem.php @@ -3,13 +3,11 @@ namespace App\Domains\Cart\Models; use App\Domains\Catalog\Models\CatalogItem; -use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\Variant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'cart_id', @@ -57,10 +55,4 @@ class CartItem extends Model { return $this->variant ?? $this->catalogItem; } - - /** @return HasMany */ - public function stockReservations(): HasMany - { - return $this->hasMany(StockReservation::class); - } } diff --git a/app/Domains/Cart/Services/ExpireCartReservationsService.php b/app/Domains/Cart/Services/ExpireCartReservationsService.php index d879994..fa23faf 100644 --- a/app/Domains/Cart/Services/ExpireCartReservationsService.php +++ b/app/Domains/Cart/Services/ExpireCartReservationsService.php @@ -3,119 +3,82 @@ namespace App\Domains\Cart\Services; use App\Domains\Cart\Models\Cart; -use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Facades\DB; class ExpireCartReservationsService { + public function __construct( + private readonly StockReservationService $reservations, + ) {} + public function expireOverdue(): int { - $expiredItems = 0; - $lastCartItemId = 0; + $expired = 0; + $lastReservationId = 0; do { - $cartItemIds = StockReservation::query() + $reservationIds = StockReservation::query() ->where('status', StockReservation::STATUS_ACTIVE) - ->whereNull('purchase_id') - ->whereNotNull('cart_item_id') ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) - ->where('cart_item_id', '>', $lastCartItemId) - ->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active')) - ->select('cart_item_id') - ->distinct() - ->orderBy('cart_item_id') + ->where('id', '>', $lastReservationId) + ->whereHas('currentCart', fn ($query) => $query->where('status', 'active')) + ->whereDoesntHave('purchase', fn ($query) => $query->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, + ])) + ->orderBy('id') ->limit(500) - ->pluck('cart_item_id'); + ->pluck('id'); - foreach ($cartItemIds as $cartItemId) { - $lastCartItemId = (int) $cartItemId; + foreach ($reservationIds as $reservationId) { + $lastReservationId = (int) $reservationId; - if ($this->expireCartItem($lastCartItemId)) { - $expiredItems++; + if ($this->expireReservation($lastReservationId)) { + $expired++; } } - } while ($cartItemIds->count() === 500); + } while ($reservationIds->count() === 500); - return $expiredItems; + return $expired; } - private function expireCartItem(int $cartItemId): bool + private function expireReservation(int $reservationId): bool { - /** @var CartItem|null $candidate */ - $candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId); - if ($candidate === null) { + $cartId = Cart::query() + ->where('current_stock_reservation_id', $reservationId) + ->where('status', 'active') + ->value('id'); + if ($cartId === null) { return false; } - return DB::transaction(function () use ($candidate, $cartItemId): bool { + return DB::transaction(function () use ($cartId, $reservationId): bool { /** @var Cart|null $cart */ $cart = Cart::query() - ->whereKey($candidate->cart_id) + ->whereKey($cartId) + ->where('current_stock_reservation_id', $reservationId) ->where('status', 'active') ->lockForUpdate() ->first(); - if ($cart === null) { return false; } - /** @var CartItem|null $cartItem */ - $cartItem = $cart->items() - ->whereKey($cartItemId) - ->lockForUpdate() - ->first(); - - if ($cartItem === null) { + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($reservationId); + if ($reservation === null + || $reservation->status !== StockReservation::STATUS_ACTIVE + || $reservation->expires_at === null + || $reservation->expires_at->isFuture()) { return false; } - $reservations = StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) - ->orderBy('inventory_id') - ->lockForUpdate() - ->get(); - - if ( - $reservations->isEmpty() - || $reservations->contains( - fn (StockReservation $reservation): bool => $reservation->purchase_id !== null - || $reservation->expires_at === null - || $reservation->expires_at->isFuture(), - ) - ) { - return false; - } - - $inventories = Inventory::query() - ->whereKey($reservations->pluck('inventory_id')) - ->orderBy('id') - ->lockForUpdate() - ->get() - ->keyBy('id'); - - foreach ($reservations as $reservation) { - $inventory = $inventories->get($reservation->inventory_id) - ?? throw new \InvalidArgumentException('No se encontro el inventario reservado.'); - - $inventory->release((int) $reservation->quantity); - $reservation->update([ - 'quantity' => 0, - 'status' => StockReservation::STATUS_EXPIRED, - 'expires_at' => null, - 'released_at' => now(), - ]); - } - - $cartItem->delete(); - - if (! $cart->items()->exists()) { - $cart->update(['status' => 'expired']); - $cart->delete(); - } + $this->reservations->expire($reservation); return true; }); diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index c15debe..4a1e567 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -48,10 +48,10 @@ class Inventory extends Model return $this->hasOne(Variant::class); } - /** @return HasMany */ - public function stockReservations(): HasMany + /** @return HasMany */ + public function stockReservationLines(): HasMany { - return $this->hasMany(StockReservation::class); + return $this->hasMany(StockReservationLine::class); } public function availableStock(): int diff --git a/app/Domains/Catalog/Models/StockReservation.php b/app/Domains/Catalog/Models/StockReservation.php index 3e4dad3..8519895 100644 --- a/app/Domains/Catalog/Models/StockReservation.php +++ b/app/Domains/Catalog/Models/StockReservation.php @@ -2,21 +2,20 @@ namespace App\Domains\Catalog\Models; -use App\Domains\Cart\Models\CartItem; +use App\Domains\Cart\Models\Cart; use App\Domains\Purchase\Models\Purchase; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ - 'inventory_id', - 'cart_item_id', - 'purchase_id', - 'quantity', 'status', 'expires_at', 'committed_at', 'released_at', + 'expired_at', + 'release_reason', ])] class StockReservation extends Model { @@ -31,31 +30,28 @@ class StockReservation extends Model protected function casts(): array { return [ - 'inventory_id' => 'integer', - 'cart_item_id' => 'integer', - 'purchase_id' => 'integer', - 'quantity' => 'integer', 'expires_at' => 'datetime', 'committed_at' => 'datetime', 'released_at' => 'datetime', + 'expired_at' => 'datetime', ]; } - /** @return BelongsTo */ - public function inventory(): BelongsTo + /** @return HasMany */ + public function lines(): HasMany { - return $this->belongsTo(Inventory::class); + return $this->hasMany(StockReservationLine::class); } - /** @return BelongsTo */ - public function cartItem(): BelongsTo + /** @return HasOne */ + public function currentCart(): HasOne { - return $this->belongsTo(CartItem::class); + return $this->hasOne(Cart::class, 'current_stock_reservation_id'); } - /** @return BelongsTo */ - public function purchase(): BelongsTo + /** @return HasOne */ + public function purchase(): HasOne { - return $this->belongsTo(Purchase::class); + return $this->hasOne(Purchase::class); } } diff --git a/app/Domains/Catalog/Models/StockReservationLine.php b/app/Domains/Catalog/Models/StockReservationLine.php new file mode 100644 index 0000000..763254b --- /dev/null +++ b/app/Domains/Catalog/Models/StockReservationLine.php @@ -0,0 +1,38 @@ + 'integer', + 'inventory_id' => 'integer', + 'quantity' => 'integer', + 'tracks_inventory' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function reservation(): BelongsTo + { + return $this->belongsTo(StockReservation::class, 'stock_reservation_id'); + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } +} diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index d384eb8..30e433a 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -25,6 +25,24 @@ class CatalogInventoryService ); } + /** + * @return array + */ + public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array + { + if ($quantity <= 0) { + throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.'); + } + + return array_map( + fn (array $requirement): array => [ + ...$requirement, + 'quantity' => $requirement['quantity'] * $quantity, + ], + $this->inventoryRequirements($selection), + ); + } + public function availableQuantity(CatalogItem|Variant $selection): ?int { if ($selection instanceof CatalogItem diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index ab29513..39f7bde 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -15,7 +15,7 @@ class ExpireStockReservationsService ) {} /** - * @return array{purchases: int, cart_items: int} + * @return array{purchases: int, cart_reservations: int} */ public function expireOverdue(): array { @@ -29,19 +29,19 @@ class ExpireStockReservationsService Log::channel('commands')->info('Stock reservation cleanup completed.', [ 'command' => 'reservations:expire', 'expired_purchases' => $expiredPurchases, - 'expired_cart_items' => $expiredCartItems, + 'expired_cart_reservations' => $expiredCartItems, 'total_expired' => $expiredPurchases + $expiredCartItems, ]); return [ 'purchases' => $expiredPurchases, - 'cart_items' => $expiredCartItems, + 'cart_reservations' => $expiredCartItems, ]; } catch (Throwable $exception) { Log::channel('commands')->error('Stock reservation cleanup failed.', [ 'command' => 'reservations:expire', 'expired_purchases' => $expiredPurchases, - 'expired_cart_items' => $expiredCartItems, + 'expired_cart_reservations' => $expiredCartItems, 'exception' => $exception, ]); diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 1b3f9d1..8215302 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -2,262 +2,415 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; -use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class StockReservationService { + public const REASON_CART_EMPTY = 'cart_empty'; + + public const REASON_CART_CHANGED = 'cart_changed'; + + public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; + + public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; + + public const REASON_PAYMENT_REJECTED = 'payment_rejected'; + + public const REASON_MANUAL_RELEASE = 'manual_release'; + public function __construct( private readonly CatalogInventoryService $inventory, ) {} - public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void + public function syncCart(Cart $cart): ?StockReservation { - DB::transaction(function () use ($cartItem, $selection, $quantity): void { - $this->inventory->reserve($selection, $quantity); - $this->recordIncrease($cartItem, $selection, $quantity); - }); - } + return DB::transaction(function () use ($cart): ?StockReservation { + /** @var Cart $lockedCart */ + $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + $items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get(); + $this->loadSelections($items); + $requirements = $this->requirementsForItems($items); - public function release( - CartItem $cartItem, - CatalogItem|Variant $selection, - int $quantity, - string $releasedStatus = StockReservation::STATUS_RELEASED, - ): void { - DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void { - $this->ensure($cartItem, $selection); - $this->inventory->release($selection, $quantity); - $this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus); - }); - } + $reservation = $lockedCart->current_stock_reservation_id === null + ? null + : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); - public function commit( - CartItem $cartItem, - CatalogItem|Variant $selection, - Purchase $purchase, - ): void { - DB::transaction(function () use ($cartItem, $selection, $purchase): void { - $this->ensure($cartItem, $selection); - $this->inventory->commit($selection, (int) $cartItem->cantidad); + 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; + } - $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); - foreach ($requirements as $inventoryId => $quantity) { - $reservation = $this->lockReservation($cartItem, $inventoryId); - if ( - $reservation === null - || $reservation->status !== StockReservation::STATUS_ACTIVE - || $reservation->purchase_id !== $purchase->getKey() - || $reservation->quantity !== $quantity - ) { - throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.'); + if ($requirements === []) { + if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) { + $this->finalizeLocked( + $reservation, + StockReservation::STATUS_RELEASED, + self::REASON_CART_EMPTY, + ); } - $reservation->update([ - 'status' => StockReservation::STATUS_COMMITTED, - 'committed_at' => now(), - 'expires_at' => null, - ]); + $lockedCart->update(['current_stock_reservation_id' => null]); + $cart->current_stock_reservation_id = null; + + return null; } - }); - } - public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void - { - $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); - - foreach ($requirements as $inventoryId => $quantity) { - $reservation = $this->lockReservation($cartItem, $inventoryId); - - if ($reservation === null) { - StockReservation::query()->create([ - 'inventory_id' => $inventoryId, - 'cart_item_id' => $cartItem->getKey(), - 'quantity' => $quantity, + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) { + $reservation = StockReservation::query()->create([ 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => $this->expiration(), ]); - - continue; + $lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]); } - if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) { - $reservation->update([ - 'quantity' => $quantity, - 'status' => StockReservation::STATUS_ACTIVE, - 'committed_at' => null, - 'released_at' => null, - 'expires_at' => $this->expiration(), - ]); + if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) { + throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.'); } - } - } - public function attachToPurchase( - CartItem $cartItem, - CatalogItem|Variant $selection, - Purchase $purchase, - ): void { - DB::transaction(function () use ($cartItem, $selection, $purchase): void { - $this->ensure($cartItem, $selection); - StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) - ->update([ - 'purchase_id' => $purchase->getKey(), - 'expires_at' => $purchase->expires_at, - ]); - }); - } - - public function detachFromPurchase(Purchase $purchase): void - { - StockReservation::query() - ->where('purchase_id', $purchase->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) - ->update([ - 'purchase_id' => null, - 'expires_at' => $this->expiration(), - ]); - } - - public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void - { - DB::transaction(function () use ($cartItem, $selection): void { - $requirements = $this->inventory->requirementsFor( - $selection, - (int) $cartItem->cantidad, - ); - $activeReservations = StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) + $currentLines = StockReservationLine::query() + ->where('stock_reservation_id', $reservation->getKey()) + ->orderBy('inventory_id') ->lockForUpdate() ->get() ->keyBy('inventory_id'); + $inventoryIds = collect(array_keys($requirements)) + ->merge($currentLines->keys()) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + $inventories = Inventory::query() + ->whereKey($inventoryIds) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); - $hasCompleteReservation = collect($requirements)->every( - fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity, - ); + foreach ($inventoryIds as $inventoryId) { + $inventory = $inventories->get($inventoryId) + ?? throw new \InvalidArgumentException('No se encontró el inventario requerido.'); + $previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0); + $required = (int) ($requirements[$inventoryId]['quantity'] ?? 0); + $delta = $required - $previous; - if ($hasCompleteReservation) { + if ($delta > 0 + && $requirements[$inventoryId]['tracks_inventory'] + && $inventory->availableStock() < $delta) { + throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.'); + } + + if ($delta < 0 && $inventory->reserved_stock < abs($delta)) { + throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.'); + } + } + + foreach ($inventoryIds as $inventoryId) { + /** @var Inventory $inventory */ + $inventory = $inventories->get($inventoryId); + $line = $currentLines->get($inventoryId); + $previous = (int) ($line?->quantity ?? 0); + $required = (int) ($requirements[$inventoryId]['quantity'] ?? 0); + $delta = $required - $previous; + + if ($delta > 0) { + $inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']); + } elseif ($delta < 0) { + $inventory->release(abs($delta)); + } + + if ($required === 0) { + $line?->delete(); + + continue; + } + + StockReservationLine::query()->updateOrCreate( + [ + 'stock_reservation_id' => $reservation->getKey(), + 'inventory_id' => $inventoryId, + ], + [ + 'quantity' => $required, + 'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'], + ], + ); + } + + $reservation->update([ + 'expires_at' => $this->expiration(), + 'release_reason' => null, + ]); + $cart->current_stock_reservation_id = $reservation->getKey(); + + return $reservation->fresh('lines'); + }); + } + + 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 */ + $lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + + if ($lockedCart->current_stock_reservation_id === null) { + throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id); + 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()) + ->whereKeyNot($lockedPurchase->getKey()) + ->exists(); + if ($linkedPurchase) { + throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.'); + } + + $lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); + $reservation->update(['expires_at' => $lockedPurchase->expires_at]); + $purchase->stock_reservation_id = $reservation->getKey(); + $cart->current_stock_reservation_id = $reservation->getKey(); + + return $reservation->fresh('lines'); + }); + } + + public function commit(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): 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.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id); + if ($reservation->status === StockReservation::STATUS_COMMITTED) { + return; + } + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + throw new \InvalidArgumentException('La reserva de stock no está activa.'); + } + + $lines = $this->lockLines($reservation); + if ($lines->isEmpty()) { + throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.'); + } + + $inventories = $this->lockInventories($lines); + foreach ($lines as $line) { + $inventory = $inventories->get($line->inventory_id) + ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); + if ($inventory->reserved_stock < $line->quantity + || ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) { + throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.'); + } + } + + foreach ($lines as $line) { + $inventories->get($line->inventory_id)->buy( + (int) $line->quantity, + (bool) $line->tracks_inventory, + ); + } + + $reservation->update([ + 'status' => StockReservation::STATUS_COMMITTED, + 'expires_at' => null, + 'committed_at' => now(), + 'released_at' => null, + 'expired_at' => null, + 'release_reason' => null, + ]); + }); + } + + public function releaseForPurchase( + Purchase $purchase, + string $status = StockReservation::STATUS_RELEASED, + ?string $reason = null, + ): void { + DB::transaction(function () use ($purchase, $status, $reason): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + if ($purchase->stock_reservation_id === null) { return; } - if ($activeReservations->isNotEmpty()) { - throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.'); + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id); + if ($reservation !== null) { + $this->finalizeLocked($reservation, $status, $reason); + } + }); + } + + public function releaseCurrentCartReservation( + Cart $cart, + string $reason = self::REASON_CART_CHANGED, + ): void { + DB::transaction(function () use ($cart, $reason): void { + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + if ($cart->current_stock_reservation_id === null) { + return; } - $this->inventory->reserve($selection, (int) $cartItem->cantidad); - $this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad); + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id); + if ($reservation !== null) { + $this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason); + } + $cart->update(['current_stock_reservation_id' => null]); + }); + } + + public function expire(StockReservation $reservation): void + { + DB::transaction(function () use ($reservation): void { + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey()); + if ($reservation->status !== StockReservation::STATUS_ACTIVE + || $reservation->expires_at === null + || $reservation->expires_at->isFuture()) { + return; + } + + $this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null); }); } public function syncPurchaseExpiration(Purchase $purchase): void { + if ($purchase->stock_reservation_id === null) { + return; + } + StockReservation::query() - ->where('purchase_id', $purchase->getKey()) + ->whereKey($purchase->stock_reservation_id) ->where('status', StockReservation::STATUS_ACTIVE) ->update(['expires_at' => $purchase->expires_at]); } - public function transfer(CartItem $source, CartItem $target): void + /** + * @param Collection $items + * @return array + */ + private function requirementsForItems(Collection $items): array { - DB::transaction(function () use ($source, $target): void { - $sourceReservations = StockReservation::query() - ->where('cart_item_id', $source->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) - ->orderBy('inventory_id') - ->lockForUpdate() - ->get(); + $requirements = []; + foreach ($items as $item) { + $selection = $item->selectedItem(); + if ($selection === null) { + throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.'); + } - foreach ($sourceReservations as $sourceReservation) { - $targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id); - - if ($targetReservation === null) { - $sourceItemQuantity = (int) $source->cantidad; - $targetItemQuantity = (int) $target->fresh()->cantidad; - $perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity); - $sourceReservation->update([ - 'cart_item_id' => $target->getKey(), - 'purchase_id' => null, - 'quantity' => $perItemQuantity * $targetItemQuantity, - 'expires_at' => $this->expiration(), - ]); + foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) { + if (isset($requirements[$inventoryId])) { + $requirements[$inventoryId]['quantity'] += $requirement['quantity']; + $requirements[$inventoryId]['tracks_inventory'] = + $requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory']; continue; } - $targetReservation->update([ - 'quantity' => $targetReservation->quantity + $sourceReservation->quantity, - 'status' => StockReservation::STATUS_ACTIVE, - 'expires_at' => $this->expiration(), - ]); - $sourceReservation->delete(); + $requirements[$inventoryId] = $requirement; } - }); - } - - private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void - { - foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { - $reservation = $this->lockReservation($cartItem, $inventoryId); - - if ($reservation === null) { - StockReservation::query()->create([ - 'inventory_id' => $inventoryId, - 'cart_item_id' => $cartItem->getKey(), - 'quantity' => $requiredQuantity, - 'status' => StockReservation::STATUS_ACTIVE, - 'expires_at' => $this->expiration(), - ]); - - continue; - } - - $reservation->update([ - 'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity, - 'status' => StockReservation::STATUS_ACTIVE, - 'committed_at' => null, - 'released_at' => null, - 'expires_at' => $this->expiration(), - ]); } + + ksort($requirements); + + return $requirements; } - private function recordDecrease( - CartItem $cartItem, - CatalogItem|Variant $selection, - int $quantity, - string $releasedStatus, - ): void { - foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { - $reservation = $this->lockReservation($cartItem, $inventoryId); - if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) { - throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.'); - } - - $remaining = $reservation->quantity - $requiredQuantity; - $reservation->update([ - 'quantity' => $remaining, - 'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE, - 'released_at' => $remaining === 0 ? now() : null, - 'expires_at' => $remaining === 0 ? null : $reservation->expires_at, - ]); - } - } - - private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation + /** @param Collection $items */ + private function loadSelections(Collection $items): void { - return StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('inventory_id', $inventoryId) + $items->load([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'catalogItem.bundleComponents.variant.catalogItem', + 'variant.inventory', + 'variant.catalogItem', + ]); + } + + /** @return Collection */ + private function lockLines(StockReservation $reservation): Collection + { + return StockReservationLine::query() + ->where('stock_reservation_id', $reservation->getKey()) + ->orderBy('inventory_id') ->lockForUpdate() - ->first(); + ->get(); + } + + /** + * @param Collection $lines + * @return Collection + */ + private function lockInventories(Collection $lines): Collection + { + return Inventory::query() + ->whereKey($lines->pluck('inventory_id')) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + } + + private function finalizeLocked( + StockReservation $reservation, + string $status, + ?string $reason, + ): void { + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + return; + } + if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) { + throw new \InvalidArgumentException('El estado final de la reserva no es válido.'); + } + + $lines = $this->lockLines($reservation); + $inventories = $this->lockInventories($lines); + foreach ($lines as $line) { + $inventory = $inventories->get($line->inventory_id) + ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); + $inventory->release((int) $line->quantity); + } + + $now = now(); + $reservation->update([ + 'status' => $status, + 'expires_at' => null, + 'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null, + 'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null, + 'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null, + ]); + + 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 6e76244..5e9a3ac 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -392,15 +392,28 @@ class InvitationPurchaseProvisioner 'sold_units' => $inventory->sold_units + 1, ]); - DB::table('stock_reservations')->insert([ + $reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id'); + if ($reservationId === null) { + $reservationId = DB::table('stock_reservations')->insertGetId([ + 'status' => 'committed', + 'expires_at' => null, + 'committed_at' => $now, + 'released_at' => null, + 'expired_at' => null, + 'release_reason' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + DB::table('compras')->where('id', $purchaseId)->update([ + 'stock_reservation_id' => $reservationId, + ]); + } + + DB::table('stock_reservation_lines')->insert([ + 'stock_reservation_id' => $reservationId, 'inventory_id' => $inventory->id, - 'cart_item_id' => null, - 'purchase_id' => $purchaseId, 'quantity' => 1, - 'status' => 'committed', - 'expires_at' => null, - 'committed_at' => $now, - 'released_at' => null, + 'tracks_inventory' => true, 'created_at' => $now, 'updated_at' => $now, ]); diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 9a37c36..58d60f3 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -19,6 +19,7 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', + 'stock_reservation_id', 'tenant_codigo', 'user_id', 'status', @@ -77,6 +78,7 @@ class Purchase extends Model { return [ 'cart_id' => 'integer', + 'stock_reservation_id' => 'integer', 'user_id' => 'integer', 'expires_at' => 'datetime', 'total' => 'decimal:2', @@ -123,10 +125,10 @@ class Purchase extends Model return $this->hasMany(Ticket::class, 'source_purchase_id'); } - /** @return HasMany */ - public function stockReservations(): HasMany + /** @return BelongsTo */ + public function stockReservation(): BelongsTo { - return $this->hasMany(StockReservation::class); + return $this->belongsTo(StockReservation::class); } /** diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index e8cd277..231fe0d 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -161,13 +161,14 @@ class CompleteCheckoutService ]); } - try { - $this->reservations->commit($cartItem, $selection, $purchase); - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); - } + } + + try { + $this->reservations->commit($purchase); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); } $this->sourceCart->finalize($purchase); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index cbb17a8..cb1263e 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -118,16 +118,40 @@ class ReleaseCheckoutService private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void { $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + try { + $this->reservations->releaseForPurchase( + $purchase, + $targetStatus === Purchase::STATUS_EXPIRED + ? StockReservation::STATUS_EXPIRED + : StockReservation::STATUS_RELEASED, + $targetStatus === Purchase::STATUS_CANCELLED + ? StockReservationService::REASON_PURCHASE_CANCELLED + : ($targetStatus === Purchase::STATUS_REJECTED + ? StockReservationService::REASON_PAYMENT_REJECTED + : null), + ); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + if ($cart === null) { return; } if ($cart->status === 'active') { - $this->reservations->detachFromPurchase($purchase); Cart::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $purchase->getKey()) - ->update(['current_purchase_id' => null]); + ->update([ + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]); + + if ($targetStatus === Purchase::STATUS_CANCELLED) { + $this->reservations->syncCart($cart); + } return; } @@ -136,37 +160,6 @@ class ReleaseCheckoutService return; } - $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); - $cartItems->load([ - 'catalogItem.inventory', - 'catalogItem.bundleComponents.catalogItem.inventory', - 'catalogItem.bundleComponents.variant.inventory', - 'variant.inventory', - 'variant.catalogItem', - ]); - - foreach ($cartItems as $cartItem) { - $selection = $cartItem->selectedItem(); - if ($selection === null) { - continue; - } - - try { - $this->reservations->release( - $cartItem, - $selection, - (int) $cartItem->cantidad, - $targetStatus === Purchase::STATUS_EXPIRED - ? StockReservation::STATUS_EXPIRED - : StockReservation::STATUS_RELEASED, - ); - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); - } - } - if (! $cart->trashed()) { $cart->update(['status' => 'converted']); $cart->delete(); diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b29d157..7c719f4 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -169,21 +169,31 @@ class StartCheckoutService 'cantidad' => $line['quantity'], ]); - try { - $this->reservations->reserve($cartItem, $line['selection'], $line['quantity']); - } catch (\InvalidArgumentException) { - $availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0; - - throw new InsufficientStockException([ - $this->unavailableItem($line, $availableQuantity), - ]); - } - $cartItem->setRelation('catalogItem', $line['catalog_item']); $cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null); $cartItems->push($cartItem); } + try { + $this->reservations->syncCart($cart); + } catch (\InvalidArgumentException) { + $unavailable = $resolvedLines + ->map(function (array $line): ?array { + $availableQuantity = $this->inventory->availableQuantity($line['selection']); + + return $availableQuantity !== null && $availableQuantity < $line['quantity'] + ? $this->unavailableItem($line, $availableQuantity) + : null; + }) + ->filter() + ->values() + ->all(); + + throw new InsufficientStockException($unavailable !== [] ? $unavailable : [ + $this->unavailableItem($resolvedLines->first(), 0), + ]); + } + $purchase = $this->createPurchase( $tenant, $userId, @@ -194,19 +204,12 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); + $this->reservations->attachToPurchase($cart, $purchase); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); $this->loadCartItems($cartItems); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); - foreach ($cartItems as $index => $cartItem) { - $this->reservations->attachToPurchase( - $cartItem, - $resolvedLines->get($index)['selection'], - $purchase, - ); - } - return $this->loadPurchase($purchase); } @@ -257,6 +260,7 @@ class StartCheckoutService $this->verifyTenantItems($tenant, $cartItems); $this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey()); $cart->setRelation('items', $cartItems); + $this->reservations->syncCart($cart); $purchase = $this->createPurchase( $tenant, @@ -266,16 +270,9 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); + $this->reservations->attachToPurchase($cart, $purchase); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); - foreach ($cartItems as $cartItem) { - $this->reservations->attachToPurchase( - $cartItem, - $cartItem->selectedItem(), - $purchase, - ); - } - return $this->loadPurchase($purchase); } @@ -320,6 +317,14 @@ class StartCheckoutService 'status' => Purchase::STATUS_SUPERSEDED, 'expires_at' => null, ]); + $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/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php index 5b5880c..be6b7c2 100644 --- a/app/Domains/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -135,11 +135,25 @@ class TenantTransactionResetService */ private function reservationQuery(array $scope): Builder { + $reservationIds = DB::table('carritos') + ->whereIn('id', $scope['cart_ids']) + ->whereNotNull('current_stock_reservation_id') + ->pluck('current_stock_reservation_id') + ->merge( + DB::table('compras') + ->whereIn('id', $scope['purchase_ids']) + ->whereNotNull('stock_reservation_id') + ->pluck('stock_reservation_id'), + ) + ->merge( + DB::table('stock_reservation_lines') + ->whereIn('inventory_id', $scope['inventory_ids']) + ->pluck('stock_reservation_id'), + ) + ->unique() + ->values(); + return DB::table('stock_reservations') - ->where(function (Builder $query) use ($scope): void { - $query->whereIn('inventory_id', $scope['inventory_ids']) - ->orWhereIn('purchase_id', $scope['purchase_ids']) - ->orWhereIn('cart_item_id', $scope['cart_item_ids']); - }); + ->whereIn('id', $reservationIds); } } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 0db5a15..e7d844a 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -88,9 +88,9 @@ class UserPurchaseLimitService $excludedCartId !== null, fn ($query) => $query->whereKeyNot($excludedCartId), )) - ->whereHas('stockReservations', fn ($query) => $query + ->whereHas('cart.currentStockReservation', fn ($query) => $query ->where('status', 'active') - ->whereNull('purchase_id')) + ->whereDoesntHave('purchase')) ->sum('cantidad'); if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { @@ -161,9 +161,9 @@ class UserPurchaseLimitService ->whereHas('cart', fn ($query) => $query ->where('user_id', $userId) ->where('status', 'active')) - ->whereHas('stockReservations', fn ($query) => $query + ->whereHas('cart.currentStockReservation', fn ($query) => $query ->where('status', 'active') - ->whereNull('purchase_id')) + ->whereDoesntHave('purchase')) ->groupBy('catalog_item_id') ->pluck('quantity', 'catalog_item_id'); diff --git a/database/migrations/2026_08_25_020000_centralize_stock_reservations.php b/database/migrations/2026_08_25_020000_centralize_stock_reservations.php new file mode 100644 index 0000000..655724f --- /dev/null +++ b/database/migrations/2026_08_25_020000_centralize_stock_reservations.php @@ -0,0 +1,180 @@ +id(); + $table->string('status')->default('active'); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('committed_at')->nullable(); + $table->dateTime('released_at')->nullable(); + $table->dateTime('expired_at')->nullable(); + $table->string('release_reason')->nullable(); + $table->timestamps(); + + $table->index(['status', 'expires_at']); + }); + + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->foreignId('stock_reservation_id')->nullable()->after('id'); + $table->boolean('tracks_inventory')->default(true)->after('quantity'); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id'); + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->foreignId('stock_reservation_id')->nullable()->after('cart_id'); + }); + + $cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id'); + $unlimitedInventoryIds = DB::table('catalog_items') + ->where('inventory_policy', 'unlimited') + ->whereNotNull('inventory_id') + ->pluck('inventory_id') + ->merge( + DB::table('variantes') + ->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id') + ->where('catalog_items.inventory_policy', 'unlimited') + ->pluck('variantes.inventory_id'), + ) + ->map(fn ($id): int => (int) $id) + ->unique(); + $legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get(); + $groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string { + if ($row->purchase_id !== null) { + return 'purchase:'.$row->purchase_id; + } + + $cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id); + + return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId; + }); + + foreach ($groups as $key => $rows) { + $statuses = $rows->pluck('status'); + $status = $statuses->contains('active') + ? 'active' + : ($statuses->contains('committed') + ? 'committed' + : ($statuses->contains('expired') ? 'expired' : 'released')); + $first = $rows->first(); + $reservationId = DB::table('stock_reservations')->insertGetId([ + 'status' => $status, + 'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null, + 'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null, + 'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null, + 'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null, + 'release_reason' => null, + 'created_at' => $first->created_at, + 'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at, + ]); + + foreach ($rows->groupBy('inventory_id') as $inventoryRows) { + $line = $inventoryRows->first(); + DB::table('stock_reservation_lines')->where('id', $line->id)->update([ + 'stock_reservation_id' => $reservationId, + 'quantity' => $inventoryRows->sum('quantity'), + 'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id), + ]); + DB::table('stock_reservation_lines') + ->whereIn('id', $inventoryRows->pluck('id')->skip(1)) + ->delete(); + } + + if (str_starts_with($key, 'purchase:')) { + $purchaseId = (int) substr($key, strlen('purchase:')); + DB::table('compras')->where('id', $purchaseId)->update([ + 'stock_reservation_id' => $reservationId, + ]); + $cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id'); + $isCurrent = $cartId !== null + && (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId; + + if ($status === 'active' && $isCurrent) { + DB::table('carritos')->where('id', $cartId)->update([ + 'current_stock_reservation_id' => $reservationId, + ]); + } + } elseif (str_starts_with($key, 'cart:') && $status === 'active') { + DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([ + 'current_stock_reservation_id' => $reservationId, + ]); + } + } + + if (DB::getDriverName() !== 'sqlite') { + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->dropForeign('stock_reservations_cart_item_id_foreign'); + $table->dropForeign('stock_reservations_purchase_id_foreign'); + $table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique'); + $table->dropIndex('stock_reservations_purchase_id_status_index'); + $table->dropIndex('stock_reservations_status_expires_at_index'); + }); + } + + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change(); + $table->dropColumn([ + 'cart_item_id', + 'purchase_id', + 'status', + 'expires_at', + 'committed_at', + 'released_at', + ]); + $table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->cascadeOnDelete(); + $table->unique( + ['stock_reservation_id', 'inventory_id'], + 'reservation_lines_reservation_inventory_unique', + ); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->nullOnDelete(); + $table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique'); + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->nullOnDelete(); + $table->unique('stock_reservation_id', 'purchases_stock_reservation_unique'); + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropUnique('purchases_stock_reservation_unique'); + $table->dropForeign('purchases_stock_reservation_fk'); + $table->dropColumn('stock_reservation_id'); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->dropUnique('carts_current_stock_reservation_unique'); + $table->dropForeign('carts_current_stock_reservation_fk'); + $table->dropColumn('current_stock_reservation_id'); + }); + + Schema::dropIfExists('stock_reservation_lines'); + Schema::dropIfExists('stock_reservations'); + } +}; diff --git a/routes/console.php b/routes/console.php index fd48c29..cc8bdf4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,7 +15,7 @@ Artisan::command('reservations:expire', function (): void { $expired = app(ExpireStockReservationsService::class)->expireOverdue(); $this->info("Expired purchases: {$expired['purchases']}"); - $this->info("Expired cart items: {$expired['cart_items']}"); + $this->info("Expired cart reservations: {$expired['cart_reservations']}"); })->purpose('Release expired stock reservations from purchases and abandoned carts'); Schedule::command('reservations:expire')