Squashed commit of the following:
commitcd99a6489bMerge:ebcbbb1e63a036Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 26 10:12:44 2026 -0300 Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations commitebcbbb1774Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit3dd378ed06Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commit4f705175bdAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commit82570fe941Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commitd2a2b323e0Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commitec67623b7bAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commitf6f138e180Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commitd612b7a118Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commit49f42d4507Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit1881cc1d4bAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit00ec37d8a0Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commitd9f374c718Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commitbbfdf8f342Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate commit8a0f29bdaeAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit198ed400a9Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commit23d83166e6Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commitcb090402d0Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commitc120b6f7c0Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commit1106fa28b4Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commit896fb81bcfAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commit8545fbc645Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commitdd6dcb708aAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit624b246ff5Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit007b95b904Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commit0b3c8c93beAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commitede718e448Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate commite63a03601aAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit389373ad8cAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commitace02a3133Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commitd69a6210f6Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commit3c0b43fea3Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commit9b0008626bAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commit61861e331aAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commit2cc9d7dd97Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commitf982bcead1Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit7a85e1731dAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit6db99e775aAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commite5f7ba3615Author: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commit24bfef431bAuthor: ncoronel <ncoronel@quo.ar> Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate
This commit is contained in:
@@ -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
|
||||
{
|
||||
@@ -36,6 +38,16 @@ class Cart extends Model
|
||||
|
||||
protected $table = 'carritos';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_CHECKOUT = 'checkout';
|
||||
|
||||
public const STATUS_CONVERTED = 'converted';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
public const ORIGIN_USER = 'user';
|
||||
|
||||
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||
@@ -45,6 +57,7 @@ class Cart extends Model
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'current_purchase_id' => 'integer',
|
||||
'current_stock_reservation_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,6 +97,15 @@ class Cart extends Model
|
||||
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<StockReservation, $this> */
|
||||
public function currentStockReservation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(
|
||||
StockReservation::class,
|
||||
'current_stock_reservation_id',
|
||||
);
|
||||
}
|
||||
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
$items = $this->relationLoaded('items')
|
||||
@@ -107,6 +129,7 @@ class Cart extends Model
|
||||
|
||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||
$cartQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
@@ -140,12 +163,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();
|
||||
});
|
||||
@@ -172,6 +194,7 @@ class Cart extends Model
|
||||
$excludedPurchaseId,
|
||||
): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -205,7 +228,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 +244,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 +255,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 +284,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();
|
||||
});
|
||||
@@ -282,6 +296,7 @@ class Cart extends Model
|
||||
{
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -289,17 +304,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,13 +347,20 @@ class Cart extends Model
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
$this->current_purchase_id = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
@@ -22,7 +25,7 @@ class CartService
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
@@ -119,7 +122,7 @@ class CartService
|
||||
{
|
||||
$cart = new Cart([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$cart->setRelation('items', collect());
|
||||
@@ -220,12 +223,14 @@ class CartService
|
||||
{
|
||||
return Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('status', 'active')
|
||||
->where('origin', Cart::ORIGIN_USER)
|
||||
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||
->when(
|
||||
$identity['user_id'] !== null,
|
||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||
)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -234,7 +239,7 @@ class CartService
|
||||
*/
|
||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||
|
||||
if ($cart === null) {
|
||||
throw new NotFoundHttpException('Cart not found.');
|
||||
@@ -247,10 +252,73 @@ class CartService
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
return $this->resolveCart($tenant, $identity)
|
||||
?? $this->createCart($tenant, $identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function resolveCart(
|
||||
Tenant $tenant,
|
||||
array $identity,
|
||||
bool $replaceExpired = true,
|
||||
): ?Cart {
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
|
||||
if ($cart?->status === Cart::STATUS_ACTIVE
|
||||
&& $cart->current_stock_reservation_id !== null
|
||||
&& app(ExpireStockReservationsService::class)
|
||||
->expireIfOverdue($cart->current_stock_reservation_id)) {
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
}
|
||||
|
||||
if ($cart?->status === Cart::STATUS_EXPIRED) {
|
||||
if (! $replaceExpired) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
return $this->replaceExpiredCart($cart, $tenant, $identity);
|
||||
}
|
||||
|
||||
if ($cart !== null) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
|
||||
/** @var Cart|null $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
|
||||
|
||||
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
|
||||
$lockedCart->update([
|
||||
'status' => Cart::STATUS_ABANDONED,
|
||||
'current_purchase_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->findCart($tenant, $identity)
|
||||
?? $this->createCart($tenant, $identity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function createCart(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$attributes = [
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
'origin' => Cart::ORIGIN_USER,
|
||||
];
|
||||
|
||||
if ($identity['user_id'] !== null) {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
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 Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpireCartReservationsService
|
||||
{
|
||||
public function expireOverdue(): int
|
||||
{
|
||||
$expiredItems = 0;
|
||||
$lastCartItemId = 0;
|
||||
|
||||
do {
|
||||
$cartItemIds = 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')
|
||||
->limit(500)
|
||||
->pluck('cart_item_id');
|
||||
|
||||
foreach ($cartItemIds as $cartItemId) {
|
||||
$lastCartItemId = (int) $cartItemId;
|
||||
|
||||
if ($this->expireCartItem($lastCartItemId)) {
|
||||
$expiredItems++;
|
||||
}
|
||||
}
|
||||
} while ($cartItemIds->count() === 500);
|
||||
|
||||
return $expiredItems;
|
||||
}
|
||||
|
||||
private function expireCartItem(int $cartItemId): bool
|
||||
{
|
||||
/** @var CartItem|null $candidate */
|
||||
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
|
||||
if ($candidate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($candidate, $cartItemId): bool {
|
||||
/** @var Cart|null $cart */
|
||||
$cart = Cart::query()
|
||||
->whereKey($candidate->cart_id)
|
||||
->where('status', 'active')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($cart === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CartItem|null $cartItem */
|
||||
$cartItem = $cart->items()
|
||||
->whereKey($cartItemId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($cartItem === null) {
|
||||
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();
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
||||
|
||||
## Modelo
|
||||
|
||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
|
||||
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
||||
|
||||
## Servicios
|
||||
|
||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
|
||||
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||
|
||||
## Endpoints
|
||||
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
|
||||
|
||||
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||
|
||||
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
|
||||
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
|
||||
|
||||
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class StockReservationExpiredException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('api.cart.reservation_expired'));
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,10 @@ class Inventory extends Model
|
||||
return $this->hasOne(Variant::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
/** @return HasMany<StockReservationLine, $this> */
|
||||
public function stockReservationLines(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
public function availableStock(): int
|
||||
|
||||
@@ -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<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
/** @return HasMany<StockReservationLine, $this> */
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CartItem, $this> */
|
||||
public function cartItem(): BelongsTo
|
||||
/** @return HasOne<Cart, $this> */
|
||||
public function currentCart(): HasOne
|
||||
{
|
||||
return $this->belongsTo(CartItem::class);
|
||||
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function purchase(): BelongsTo
|
||||
/** @return HasOne<Purchase, $this> */
|
||||
public function purchase(): HasOne
|
||||
{
|
||||
return $this->belongsTo(Purchase::class);
|
||||
return $this->hasOne(Purchase::class);
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'stock_reservation_id',
|
||||
'inventory_id',
|
||||
'quantity',
|
||||
'tracks_inventory',
|
||||
])]
|
||||
class StockReservationLine extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'stock_reservation_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'tracks_inventory' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<StockReservation, $this> */
|
||||
public function reservation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,24 @@ class CatalogInventoryService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -2,50 +2,184 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExpireStockReservationsService
|
||||
{
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkout,
|
||||
private readonly ExpireCartReservationsService $carts,
|
||||
private readonly ReleaseCheckoutService $purchases,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{purchases: int, cart_items: int}
|
||||
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||
*/
|
||||
public function expireOverdue(): array
|
||||
{
|
||||
$expiredPurchases = null;
|
||||
$expiredCartItems = null;
|
||||
$summary = [
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$lastReservationId = 0;
|
||||
|
||||
try {
|
||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
||||
$expiredCartItems = $this->carts->expireOverdue();
|
||||
do {
|
||||
$reservationIds = StockReservation::query()
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->where('id', '>', $lastReservationId)
|
||||
->orderBy('id')
|
||||
->limit(self::BATCH_SIZE)
|
||||
->pluck('id');
|
||||
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_items' => $expiredCartItems,
|
||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
||||
]);
|
||||
foreach ($reservationIds as $reservationId) {
|
||||
$lastReservationId = (int) $reservationId;
|
||||
|
||||
return [
|
||||
'purchases' => $expiredPurchases,
|
||||
'cart_items' => $expiredCartItems,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('commands')->error('Stock reservation cleanup failed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_items' => $expiredCartItems,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
try {
|
||||
$owner = $this->expireReservation($lastReservationId);
|
||||
if ($owner !== null) {
|
||||
$summary[$owner]++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$summary['failed']++;
|
||||
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||
'command' => 'reservations:expire',
|
||||
'stock_reservation_id' => $lastReservationId,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||
|
||||
throw $exception;
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $summary['purchases'],
|
||||
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||
'failed_reservations' => $summary['failed'],
|
||||
'total_expired' => $summary['purchases']
|
||||
+ $summary['cart_reservations']
|
||||
+ $summary['orphan_reservations'],
|
||||
]);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function expireIfOverdue(int $reservationId): bool
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||
return true;
|
||||
}
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expireReservation($reservationId) !== null;
|
||||
}
|
||||
|
||||
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||
private function expireReservation(int $reservationId): ?string
|
||||
{
|
||||
$purchaseId = Purchase::query()
|
||||
->where('stock_reservation_id', $reservationId)
|
||||
->value('id');
|
||||
if ($purchaseId !== null) {
|
||||
return $this->expirePurchase((int) $purchaseId);
|
||||
}
|
||||
|
||||
$cartId = Cart::query()
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', 'active')
|
||||
->value('id');
|
||||
if ($cartId !== null) {
|
||||
return $this->expireCart((int) $cartId, $reservationId);
|
||||
}
|
||||
|
||||
return $this->expireOrphan($reservationId);
|
||||
}
|
||||
|
||||
/** @return 'purchases'|null */
|
||||
private function expirePurchase(int $purchaseId): ?string
|
||||
{
|
||||
/** @var Purchase|null $purchase */
|
||||
$purchase = Purchase::query()->find($purchaseId);
|
||||
if ($purchase === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$purchase = $this->purchases->expire($purchase);
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
return 'purchases';
|
||||
}
|
||||
|
||||
$reservation = $purchase->stockReservation;
|
||||
if ($this->isOverdue($reservation)) {
|
||||
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return 'cart_reservations'|null */
|
||||
private function expireCart(int $cartId, int $reservationId): ?string
|
||||
{
|
||||
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||
/** @var Cart|null $cart */
|
||||
$cart = Cart::query()
|
||||
->whereKey($cartId)
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', Cart::STATUS_ACTIVE)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($cart === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||
|
||||
return 'cart_reservations';
|
||||
});
|
||||
}
|
||||
|
||||
/** @return 'orphan_reservations'|null */
|
||||
private function expireOrphan(int $reservationId): ?string
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
|
||||
return 'orphan_reservations';
|
||||
}
|
||||
|
||||
private function isOverdue(?StockReservation $reservation): bool
|
||||
{
|
||||
return $reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,262 +2,486 @@
|
||||
|
||||
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\Exceptions\StockReservationExpiredException;
|
||||
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) {
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
}
|
||||
|
||||
$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,
|
||||
$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');
|
||||
|
||||
$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')
|
||||
$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();
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($sourceReservations as $sourceReservation) {
|
||||
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_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 ($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(),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
$targetReservation->update([
|
||||
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
$sourceReservation->delete();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
||||
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||
{
|
||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||
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 ($reservation === null) {
|
||||
StockReservation::query()->create([
|
||||
'inventory_id' => $inventoryId,
|
||||
'cart_item_id' => $cartItem->getKey(),
|
||||
'quantity' => $requiredQuantity,
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
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.');
|
||||
}
|
||||
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
/** @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');
|
||||
});
|
||||
}
|
||||
|
||||
private function recordDecrease(
|
||||
CartItem $cartItem,
|
||||
CatalogItem|Variant $selection,
|
||||
int $quantity,
|
||||
string $releasedStatus,
|
||||
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 {
|
||||
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.');
|
||||
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;
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
/** @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<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 lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
||||
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||
{
|
||||
return StockReservation::query()
|
||||
->where('cart_item_id', $cartItem->getKey())
|
||||
->where('inventory_id', $inventoryId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||
- `Inventory` administra stock disponible, reservado y comprado.
|
||||
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
|
||||
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||
- `BundleComponent` representa los componentes de un paquete.
|
||||
@@ -18,7 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
||||
|
||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
|
||||
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
|
||||
if ($purchaseId !== null) {
|
||||
DB::table('compras')->where('id', $purchaseId)->update([
|
||||
'status' => 'paid',
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
|
||||
'cart_id' => null,
|
||||
'status' => 'paid',
|
||||
'payment_method' => self::PAYMENT_METHOD,
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'dni' => null,
|
||||
'transfer_payer_dni' => null,
|
||||
@@ -392,15 +390,27 @@ 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',
|
||||
'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,
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,7 @@ class PurchaseController extends Controller
|
||||
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->with('stockReservation')
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
||||
@@ -101,7 +102,13 @@ class PurchaseController extends Controller
|
||||
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
||||
: null;
|
||||
|
||||
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
|
||||
$updated = DB::transaction(function () use (
|
||||
$checkoutService,
|
||||
$compra,
|
||||
$method,
|
||||
$purchaseState,
|
||||
$transferPayerDni,
|
||||
): bool {
|
||||
/** @var Purchase|null $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->whereKey($compra->getKey())
|
||||
@@ -128,9 +135,6 @@ class PurchaseController extends Controller
|
||||
$purchaseUpdate = [
|
||||
'payment_method' => $method,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
||||
),
|
||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||
];
|
||||
|
||||
@@ -138,6 +142,12 @@ class PurchaseController extends Controller
|
||||
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
|
||||
}
|
||||
$purchase->update($purchaseUpdate);
|
||||
$checkoutService->refreshReservationExpiration(
|
||||
$purchase,
|
||||
now()->addMinutes(
|
||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
|
||||
),
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -150,7 +160,6 @@ class PurchaseController extends Controller
|
||||
|
||||
$compra->refresh();
|
||||
$totalAmount = (float) $compra->total;
|
||||
$checkoutService->syncReservationExpiration($compra);
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
|
||||
@@ -19,11 +19,11 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'cart_id',
|
||||
'stock_reservation_id',
|
||||
'tenant_codigo',
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'expires_at',
|
||||
'total',
|
||||
'dni',
|
||||
'transfer_payer_dni',
|
||||
@@ -77,8 +77,8 @@ class Purchase extends Model
|
||||
{
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'stock_reservation_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -123,10 +123,10 @@ class Purchase extends Model
|
||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
/** @return BelongsTo<StockReservation, $this> */
|
||||
public function stockReservation(): BelongsTo
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
return $this->belongsTo(StockReservation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,7 @@ class PurchaseResource extends JsonResource
|
||||
'created_at' => $this->created_at,
|
||||
'status' => $this->status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'expires_at' => $this->expires_at,
|
||||
'expires_at' => $this->stockReservation?->expires_at,
|
||||
'dni' => $this->dni,
|
||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||
'telefono' => $this->telefono,
|
||||
|
||||
@@ -61,10 +61,7 @@ class CompleteCheckoutService
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
@@ -72,9 +69,8 @@ class CompleteCheckoutService
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$this->reservations->syncPurchaseExpiration($purchase);
|
||||
$this->reservations->refreshForPurchase($purchase, null);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
@@ -161,13 +157,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);
|
||||
@@ -194,7 +191,7 @@ class CompleteCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
|
||||
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||
|
||||
@@ -8,6 +8,6 @@ class PurchaseResponseLoader
|
||||
{
|
||||
public function load(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['tenant', 'items.imageAttachment']);
|
||||
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class ReleaseCheckoutService
|
||||
{
|
||||
@@ -32,40 +31,6 @@ class ReleaseCheckoutService
|
||||
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
public function expireOverdue(): int
|
||||
{
|
||||
$expiredCount = 0;
|
||||
|
||||
Purchase::query()
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->orderBy('id')
|
||||
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
|
||||
try {
|
||||
$purchase = $this->expire($purchase);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('commands')->error('Failed to expire overdue purchase.', [
|
||||
'command' => 'reservations:expire',
|
||||
'purchase_id' => $purchase->getKey(),
|
||||
'tenant_codigo' => $purchase->tenant_codigo,
|
||||
'cart_id' => $purchase->cart_id,
|
||||
'status' => $purchase->status,
|
||||
'expires_at' => $purchase->expires_at,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
$expiredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
private function release(
|
||||
Purchase $purchase,
|
||||
string $targetStatus,
|
||||
@@ -78,6 +43,11 @@ class ReleaseCheckoutService
|
||||
): Purchase {
|
||||
$purchase = $this->lockPurchase($purchase);
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||
return $this->loadPurchase($purchase);
|
||||
@@ -100,14 +70,31 @@ class ReleaseCheckoutService
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
|
||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||
&& $cart?->status === 'active'
|
||||
&& in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$this->reservations->returnToCart($purchase, $cart);
|
||||
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
if (
|
||||
$targetStatus === Purchase::STATUS_EXPIRED
|
||||
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
|
||||
&& (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true) || ! $this->hasOverdueActiveReservation($purchase))
|
||||
) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus);
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
|
||||
|
||||
$purchase->update(['status' => $targetStatus]);
|
||||
|
||||
@@ -115,60 +102,67 @@ class ReleaseCheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
|
||||
{
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
private function releasePurchaseReservations(
|
||||
Purchase $purchase,
|
||||
string $targetStatus,
|
||||
?Cart $cart,
|
||||
): void {
|
||||
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);
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED
|
||||
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update(['current_purchase_id' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status !== 'checkout') {
|
||||
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'),
|
||||
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
|
||||
->update([
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_purchase_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status === Cart::STATUS_ACTIVE) {
|
||||
$cartUpdate = [
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
];
|
||||
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update($cartUpdate);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_CHECKOUT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $cart->trashed()) {
|
||||
$cart->update(['status' => 'converted']);
|
||||
$cart->update(['status' => Cart::STATUS_CONVERTED]);
|
||||
$cart->delete();
|
||||
}
|
||||
}
|
||||
@@ -218,6 +212,17 @@ class ReleaseCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
|
||||
private function hasOverdueActiveReservation(Purchase $purchase): bool
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
|
||||
|
||||
return $reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
@@ -12,6 +13,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -169,21 +171,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 +206,12 @@ class StartCheckoutService
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||
|
||||
$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 +262,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 +272,9 @@ class StartCheckoutService
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||
|
||||
foreach ($cartItems as $cartItem) {
|
||||
$this->reservations->attachToPurchase(
|
||||
$cartItem,
|
||||
$cartItem->selectedItem(),
|
||||
$purchase,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
@@ -316,9 +315,9 @@ class StartCheckoutService
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$this->reservations->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -331,7 +330,11 @@ class StartCheckoutService
|
||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||
}
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
if ($cart->status === Cart::STATUS_EXPIRED) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
@@ -405,13 +408,17 @@ class StartCheckoutService
|
||||
'user_id' => $userId,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
'payment_method' => null,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkoutExpiration(): Carbon
|
||||
{
|
||||
return now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, CartItem> $cartItems */
|
||||
private function loadCartItems(Collection $cartItems): void
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -78,13 +79,8 @@ class CheckoutService
|
||||
return $this->releaser->expire($purchase);
|
||||
}
|
||||
|
||||
public function expireOverduePurchases(): int
|
||||
public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void
|
||||
{
|
||||
return $this->releaser->expireOverdue();
|
||||
}
|
||||
|
||||
public function syncReservationExpiration(Purchase $purchase): void
|
||||
{
|
||||
$this->reservations->syncPurchaseExpiration($purchase);
|
||||
$this->reservations->refreshForPurchase($purchase, $expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -11,15 +12,32 @@ class PurchaseStateGuard
|
||||
{
|
||||
public function assertNotExpired(Purchase $purchase): void
|
||||
{
|
||||
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
|
||||
$hasExpiredByTime = in_array($purchase->status, [
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
&& $purchase->expires_at !== null
|
||||
&& $purchase->expires_at->isPast();
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasExpiredStatus || $hasExpiredByTime) {
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $purchase->relationLoaded('stockReservation')
|
||||
? $purchase->getRelation('stockReservation')
|
||||
: ($purchase->exists
|
||||
? $purchase->stockReservation()->first()
|
||||
: null);
|
||||
|
||||
if ($reservation !== null && (
|
||||
$reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| (
|
||||
$reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture()
|
||||
)
|
||||
)) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
||||
|
||||
## Modelo
|
||||
|
||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`.
|
||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
|
||||
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
||||
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||
@@ -15,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
||||
|
||||
`CheckoutService` es la fachada estable. Delega en:
|
||||
|
||||
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
|
||||
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
|
||||
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
||||
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
||||
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
||||
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
|
||||
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||
|
||||
Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||
|
||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||
|
||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user