refactor(stock): centralize reservation aggregate

This commit is contained in:
2026-08-25 15:05:23 -03:00
parent 843659583e
commit 24bfef431b
18 changed files with 783 additions and 419 deletions

View File

@@ -19,6 +19,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
@@ -77,6 +78,7 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -123,10 +125,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);
}
/**

View File

@@ -161,13 +161,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);

View File

@@ -118,16 +118,40 @@ class ReleaseCheckoutService
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
{
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
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);
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update(['current_purchase_id' => null]);
->update([
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
if ($targetStatus === Purchase::STATUS_CANCELLED) {
$this->reservations->syncCart($cart);
}
return;
}
@@ -136,37 +160,6 @@ class ReleaseCheckoutService
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'),
]);
}
}
if (! $cart->trashed()) {
$cart->update(['status' => 'converted']);
$cart->delete();

View File

@@ -169,21 +169,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 +204,12 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase);
$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 +260,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 +270,9 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -320,6 +317,14 @@ class StartCheckoutService
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->reservations->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
$cart->update([
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
}
return $cart;

View File

@@ -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);
}
}

View File

@@ -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');