commit1dc4e29c69Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 13:58:03 2026 -0300 refactor(reservations): unify expiration command commit093e894cc3Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 13:48:09 2026 -0300 feat(cart): expire abandoned stock reservations commitfdf0f3328fAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:53:12 2026 -0300 refactor(stock): implement expiration for stock reservations and add configuration commit8d6bcdcc43Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:38:21 2026 -0300 refactor(cart): invalidate payment on actual changes commit3206e293ebAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:24:48 2026 -0300 refactor(cart): own checkout item editing commitaed99bd05eAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:14:57 2026 -0300 refactor(checkout): remove legacy purchase item reservations commitf1649e0e4bAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:06:29 2026 -0300 refactor(checkout): materialize purchase items on confirmation commite6c4b40a37Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:06:19 2026 -0300 feat(inventory): add traceable cart stock reservations
231 lines
9.0 KiB
PHP
231 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Catalog\Services;
|
|
|
|
use App\Domains\Cart\Models\CartItem;
|
|
use App\Domains\Catalog\Models\CatalogItem;
|
|
use App\Domains\Catalog\Models\StockReservation;
|
|
use App\Domains\Catalog\Models\Variant;
|
|
use App\Domains\Purchase\Models\Purchase;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class StockReservationService
|
|
{
|
|
public function __construct(
|
|
private readonly CatalogInventoryService $inventory,
|
|
) {}
|
|
|
|
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
|
{
|
|
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
|
|
$this->inventory->reserve($selection, $quantity);
|
|
$this->recordIncrease($cartItem, $selection, $quantity);
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
{
|
|
DB::transaction(function () use ($cartItem, $selection): void {
|
|
$this->ensure($cartItem, $selection);
|
|
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
|
|
|
$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->quantity !== $quantity) {
|
|
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
|
}
|
|
|
|
$reservation->update([
|
|
'status' => StockReservation::STATUS_COMMITTED,
|
|
'committed_at' => now(),
|
|
'expires_at' => 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,
|
|
'status' => StockReservation::STATUS_ACTIVE,
|
|
'expires_at' => $this->expiration(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 syncPurchaseExpiration(Purchase $purchase): void
|
|
{
|
|
StockReservation::query()
|
|
->where('purchase_id', $purchase->getKey())
|
|
->where('status', StockReservation::STATUS_ACTIVE)
|
|
->update(['expires_at' => $purchase->expires_at]);
|
|
}
|
|
|
|
public function transfer(CartItem $source, CartItem $target): void
|
|
{
|
|
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();
|
|
|
|
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(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$targetReservation->update([
|
|
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
|
'status' => StockReservation::STATUS_ACTIVE,
|
|
'expires_at' => $this->expiration(),
|
|
]);
|
|
$sourceReservation->delete();
|
|
}
|
|
});
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
return StockReservation::query()
|
|
->where('cart_item_id', $cartItem->getKey())
|
|
->where('inventory_id', $inventoryId)
|
|
->lockForUpdate()
|
|
->first();
|
|
}
|
|
|
|
private function expiration(): Carbon
|
|
{
|
|
return now()->addMinutes(
|
|
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
|
|
);
|
|
}
|
|
}
|