lockForUpdate()->findOrFail($cart->getKey()); $items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get(); $this->loadSelections($items); $requirements = $this->requirementsForItems($items); $reservation = $lockedCart->current_stock_reservation_id === null ? null : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); if ($reservation !== null) { $this->assertUsableCartReservation($reservation); } if ($requirements === []) { if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) { $this->finalizeLocked( $reservation, StockReservation::STATUS_RELEASED, self::REASON_CART_EMPTY, ); } $lockedCart->update(['current_stock_reservation_id' => null]); $cart->current_stock_reservation_id = null; return null; } if ($reservation === null) { $reservation = StockReservation::query()->create([ 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => $this->expiration(), ]); $lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]); } if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) { throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.'); } $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'); 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 ($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, Carbon $expiresAt, ): StockReservation { return DB::transaction(function () use ($cart, $purchase, $expiresAt): 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); $this->assertUsableCartReservation($reservation); $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' => $expiresAt]); $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.'); } if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { throw new StockReservationExpiredException; } $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; } /** @var StockReservation|null $reservation */ $reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id); if ($reservation !== null) { $this->finalizeLocked($reservation, $status, $reason); } }); } 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, ): 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; } /** @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 refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): 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.'); } /** @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]); }); } /** * @param Collection $items * @return array */ private function requirementsForItems(Collection $items): array { $requirements = []; foreach ($items as $item) { $selection = $item->selectedItem(); if ($selection === null) { throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.'); } 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; } $requirements[$inventoryId] = $requirement; } } ksort($requirements); return $requirements; } /** @param Collection $items */ private function loadSelections(Collection $items): void { $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() ->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, ]); 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.'); } } private function expiration(): Carbon { return now()->addMinutes( max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)), ); } }