494 lines
20 KiB
PHP
494 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Catalog\Services;
|
|
|
|
use App\Domains\Cart\Models\Cart;
|
|
use App\Domains\Cart\Models\CartItem;
|
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
|
use App\Domains\Catalog\Models\Inventory;
|
|
use App\Domains\Catalog\Models\StockReservation;
|
|
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 syncCart(Cart $cart): ?StockReservation
|
|
{
|
|
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);
|
|
|
|
$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 clearExpirationForReview(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_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' => null]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, CartItem> $items
|
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
|
*/
|
|
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<int, CartItem> $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<int, StockReservationLine> */
|
|
private function lockLines(StockReservation $reservation): Collection
|
|
{
|
|
return StockReservationLine::query()
|
|
->where('stock_reservation_id', $reservation->getKey())
|
|
->orderBy('inventory_id')
|
|
->lockForUpdate()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, StockReservationLine> $lines
|
|
* @return Collection<int, Inventory>
|
|
*/
|
|
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)),
|
|
);
|
|
}
|
|
}
|