diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index cfddda5..57edbfb 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -7,6 +7,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -24,6 +26,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 'user_id', 'guest_token', 'status', + 'origin', ])] class Cart extends Model { @@ -32,6 +35,10 @@ class Cart extends Model protected $table = 'carritos'; + public const ORIGIN_USER = 'user'; + + public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout'; + protected function casts(): array { return [ @@ -63,6 +70,12 @@ class Cart extends Model return $this->hasMany(CartItem::class, 'cart_id'); } + /** @return HasMany */ + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class, 'cart_id'); + } + public function getTotalAmount(): float { $items = $this->relationLoaded('items') @@ -114,11 +127,12 @@ class Cart extends Model 'cantidad' => $quantity, ]); } else { + app(StockReservationService::class)->ensure($item, $selectedItem); $item->cantidad += $quantity; $item->save(); } - $inventoryService->reserve($selectedItem, $quantity); + app(StockReservationService::class)->reserve($item, $selectedItem, $quantity); return $item->fresh(); }); @@ -170,7 +184,7 @@ class Cart extends Model $otherVariantsQuantity + $quantity, ); - $inventoryService->release($currentSelection, $item->cantidad); + app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad); $availableQuantity = $inventoryService->availableQuantity($nextSelection); if ($availableQuantity !== null && $availableQuantity < $quantity) { @@ -186,11 +200,11 @@ class Cart extends Model ->lockForUpdate() ->first(); - $inventoryService->reserve($nextSelection, $quantity); - if ($targetItem !== null) { + app(StockReservationService::class)->ensure($targetItem, $nextSelection); $targetItem->cantidad += $quantity; $targetItem->save(); + app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity); $item->delete(); return $targetItem->fresh(); @@ -199,6 +213,7 @@ class Cart extends Model $item->variant_id = $variantId; $item->cantidad = $quantity; $item->save(); + app(StockReservationService::class)->reserve($item, $nextSelection, $quantity); return $item->fresh(); } @@ -225,17 +240,17 @@ class Cart extends Model ]); } - $item->cantidad = $quantity; - $item->save(); - if ($delta > 0) { - $inventoryService->reserve($currentSelection, $delta); + app(StockReservationService::class)->reserve($item, $currentSelection, $delta); } if ($delta < 0) { - $inventoryService->release($currentSelection, abs($delta)); + app(StockReservationService::class)->release($item, $currentSelection, abs($delta)); } + $item->cantidad = $quantity; + $item->save(); + return $item->fresh(); }); } @@ -254,7 +269,8 @@ class Cart extends Model $item->variant_id, true, ); - app(CatalogInventoryService::class)->release( + app(StockReservationService::class)->release( + $item, $selectedItem, $item->cantidad, ); diff --git a/app/Domains/Cart/Models/CartItem.php b/app/Domains/Cart/Models/CartItem.php index 3841533..8ece5b4 100644 --- a/app/Domains/Cart/Models/CartItem.php +++ b/app/Domains/Cart/Models/CartItem.php @@ -3,11 +3,13 @@ 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', @@ -55,4 +57,10 @@ class CartItem extends Model { return $this->variant ?? $this->catalogItem; } + + /** @return HasMany */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } } diff --git a/app/Domains/Cart/Services/GuestCartMergeService.php b/app/Domains/Cart/Services/GuestCartMergeService.php index 5504a4e..0e600fa 100644 --- a/app/Domains/Cart/Services/GuestCartMergeService.php +++ b/app/Domains/Cart/Services/GuestCartMergeService.php @@ -34,6 +34,10 @@ class GuestCartMergeService ->first(); if ($userCart !== null) { + $userCart->items() + ->orderBy('id') + ->pluck('id') + ->each(fn (int $itemId) => $userCart->removeItem($itemId)); $userCart->update([ 'status' => 'converted', ]); diff --git a/app/Domains/Cart/documentacion/README.md b/app/Domains/Cart/documentacion/README.md index 3f2edf4..a382b1f 100644 --- a/app/Domains/Cart/documentacion/README.md +++ b/app/Domains/Cart/documentacion/README.md @@ -7,7 +7,7 @@ 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. -- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva. +- `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 @@ -30,3 +30,5 @@ Bajo `/tenants/{tenant:codigo}`: ## Dependencias y reglas Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual. + +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. diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 4bd24e7..c15debe 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ @@ -47,6 +48,12 @@ class Inventory extends Model return $this->hasOne(Variant::class); } + /** @return HasMany */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } + public function availableStock(): int { return max(0, $this->real_stock - $this->reserved_stock); diff --git a/app/Domains/Catalog/Models/StockReservation.php b/app/Domains/Catalog/Models/StockReservation.php new file mode 100644 index 0000000..3e4dad3 --- /dev/null +++ b/app/Domains/Catalog/Models/StockReservation.php @@ -0,0 +1,61 @@ + 'integer', + 'cart_item_id' => 'integer', + 'purchase_id' => 'integer', + 'quantity' => 'integer', + 'expires_at' => 'datetime', + 'committed_at' => 'datetime', + 'released_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return BelongsTo */ + public function cartItem(): BelongsTo + { + return $this->belongsTo(CartItem::class); + } + + /** @return BelongsTo */ + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } +} diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index f1c01be..d384eb8 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -12,6 +12,19 @@ use Illuminate\Support\Facades\DB; class CatalogInventoryService { + /** @return array */ + public function requirementsFor(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): int => $requirement['quantity'] * $quantity, + $this->inventoryRequirements($selection), + ); + } + public function availableQuantity(CatalogItem|Variant $selection): ?int { if ($selection instanceof CatalogItem diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php new file mode 100644 index 0000000..e6a1053 --- /dev/null +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -0,0 +1,217 @@ +inventory->reserve($selection, $quantity); + $this->recordIncrease($cartItem, $selection, $quantity); + }); + } + + public function release( + CartItem $cartItem, + CatalogItem|Variant $selection, + int $quantity, + string $releasedStatus = StockReservation::STATUS_RELEASED, + ): void { + DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void { + $this->ensure($cartItem, $selection); + $this->inventory->release($selection, $quantity); + $this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus); + }); + } + + public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void + { + DB::transaction(function () use ($cartItem, $selection): void { + $this->ensure($cartItem, $selection); + $this->inventory->commit($selection, (int) $cartItem->cantidad); + + $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); + foreach ($requirements as $inventoryId => $quantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) { + throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.'); + } + + $reservation->update([ + 'status' => StockReservation::STATUS_COMMITTED, + 'committed_at' => now(), + 'expires_at' => null, + ]); + } + }); + } + + public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void + { + $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); + + foreach ($requirements as $inventoryId => $quantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + + if ($reservation === null) { + StockReservation::query()->create([ + 'inventory_id' => $inventoryId, + 'cart_item_id' => $cartItem->getKey(), + 'quantity' => $quantity, + 'status' => StockReservation::STATUS_ACTIVE, + ]); + + continue; + } + + if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) { + $reservation->update([ + 'quantity' => $quantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'committed_at' => null, + 'released_at' => null, + ]); + } + } + } + + 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' => null, + ]); + } + + public function syncPurchaseExpiration(Purchase $purchase): void + { + StockReservation::query() + ->where('purchase_id', $purchase->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->update(['expires_at' => $purchase->expires_at]); + } + + public function transfer(CartItem $source, CartItem $target): void + { + DB::transaction(function () use ($source, $target): void { + $sourceReservations = StockReservation::query() + ->where('cart_item_id', $source->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->orderBy('inventory_id') + ->lockForUpdate() + ->get(); + + foreach ($sourceReservations as $sourceReservation) { + $targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id); + + if ($targetReservation === null) { + $sourceItemQuantity = (int) $source->cantidad; + $targetItemQuantity = (int) $target->fresh()->cantidad; + $perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity); + $sourceReservation->update([ + 'cart_item_id' => $target->getKey(), + 'purchase_id' => null, + 'quantity' => $perItemQuantity * $targetItemQuantity, + 'expires_at' => null, + ]); + + continue; + } + + $targetReservation->update([ + 'quantity' => $targetReservation->quantity + $sourceReservation->quantity, + 'status' => StockReservation::STATUS_ACTIVE, + ]); + $sourceReservation->delete(); + } + }); + } + + private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void + { + foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + + if ($reservation === null) { + StockReservation::query()->create([ + 'inventory_id' => $inventoryId, + 'cart_item_id' => $cartItem->getKey(), + 'quantity' => $requiredQuantity, + 'status' => StockReservation::STATUS_ACTIVE, + ]); + + continue; + } + + $reservation->update([ + 'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'committed_at' => null, + 'released_at' => null, + ]); + } + } + + private function recordDecrease( + CartItem $cartItem, + CatalogItem|Variant $selection, + int $quantity, + string $releasedStatus, + ): void { + foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) { + throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.'); + } + + $remaining = $reservation->quantity - $requiredQuantity; + $reservation->update([ + 'quantity' => $remaining, + 'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE, + 'released_at' => $remaining === 0 ? now() : null, + 'expires_at' => $remaining === 0 ? null : $reservation->expires_at, + ]); + } + } + + private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation + { + return StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('inventory_id', $inventoryId) + ->lockForUpdate() + ->first(); + } +} diff --git a/app/Domains/Catalog/documentacion/README.md b/app/Domains/Catalog/documentacion/README.md index 9ecf3aa..baa2bb3 100644 --- a/app/Domains/Catalog/documentacion/README.md +++ b/app/Domains/Catalog/documentacion/README.md @@ -9,6 +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`. - `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. @@ -17,6 +18,7 @@ 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`. - `FeaturedGroupService`: pagina los ítems destacados para la tienda. - `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets. diff --git a/database/migrations/2026_08_19_000100_create_stock_reservations_table.php b/database/migrations/2026_08_19_000100_create_stock_reservations_table.php new file mode 100644 index 0000000..6529cf6 --- /dev/null +++ b/database/migrations/2026_08_19_000100_create_stock_reservations_table.php @@ -0,0 +1,59 @@ +string('origin')->default('user')->after('status'); + }); + + Schema::create('stock_reservations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete(); + $table->foreignId('cart_item_id')->nullable()->constrained('carrito_items')->nullOnDelete(); + $table->foreignId('purchase_id')->nullable()->constrained('compras')->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->string('status')->default('active'); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('committed_at')->nullable(); + $table->dateTime('released_at')->nullable(); + $table->timestamps(); + + $table->unique(['cart_item_id', 'inventory_id']); + $table->index(['purchase_id', 'status']); + $table->index(['status', 'expires_at']); + }); + + CartItem::query() + ->whereHas('cart', fn ($query) => $query->where('status', 'active')) + ->with([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'variant.inventory', + 'variant.catalogItem', + ]) + ->eachById(function (CartItem $cartItem): void { + $selection = $cartItem->selectedItem(); + if ($selection !== null) { + app(StockReservationService::class)->ensure($cartItem, $selection); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_reservations'); + + Schema::table('carritos', function (Blueprint $table): void { + $table->dropColumn('origin'); + }); + } +}; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 54398d2..0238693 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -73,6 +73,11 @@ class CartControllerTest extends TestCase 'id' => $item->inventory_id, 'reserved_stock' => 2, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $item->inventory_id, + 'quantity' => 2, + 'status' => 'active', + ]); } public function test_it_filters_item_images_when_the_tenant_disables_them(): void @@ -129,6 +134,12 @@ class CartControllerTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 5, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => $response->json('data.items.0.id'), + 'inventory_id' => $variant->inventory_id, + 'quantity' => 5, + 'status' => 'active', + ]); } public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void @@ -283,6 +294,12 @@ class CartControllerTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 0, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => null, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 0, + 'status' => 'released', + ]); } public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void