262 lines
10 KiB
PHP
262 lines
10 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 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)
|
|
->lockForUpdate()
|
|
->get()
|
|
->keyBy('inventory_id');
|
|
|
|
$hasCompleteReservation = collect($requirements)->every(
|
|
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
|
|
);
|
|
|
|
if ($hasCompleteReservation) {
|
|
return;
|
|
}
|
|
|
|
if ($activeReservations->isNotEmpty()) {
|
|
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
|
}
|
|
|
|
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
|
|
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
|
|
});
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|
|
}
|