From 480ee7039357fb10530b59767500f3b104d92cb9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 14 Jul 2026 16:41:34 -0300 Subject: [PATCH] feat: refactor cart and bundle handling to support Buyable interface for improved flexibility --- app/Domains/Bundle/Models/Bundle.php | 83 ++++++++--------- .../Cart/Controllers/CartController.php | 13 ++- app/Domains/Cart/Models/Cart.php | 93 ++++++++++++------- .../Cart/Requests/AddCartItemRequest.php | 3 +- .../Cart/Resources/CartItemResource.php | 38 +++----- app/Domains/Cart/Services/CartService.php | 12 +-- app/Domains/Cart/routes/api.php | 4 +- app/Domains/Catalog/Models/ProductVariant.php | 29 +++++- .../Resources/PurchaseItemResource.php | 44 ++++----- .../Purchase/Services/CheckoutService.php | 79 +++++++--------- app/Domains/Shared/Contracts/Buyable.php | 20 ++++ app/Domains/Shared/Contracts/Stockable.php | 15 --- 12 files changed, 230 insertions(+), 203 deletions(-) create mode 100644 app/Domains/Shared/Contracts/Buyable.php delete mode 100644 app/Domains/Shared/Contracts/Stockable.php diff --git a/app/Domains/Bundle/Models/Bundle.php b/app/Domains/Bundle/Models/Bundle.php index 5b1b2f6..16ab605 100644 --- a/app/Domains/Bundle/Models/Bundle.php +++ b/app/Domains/Bundle/Models/Bundle.php @@ -2,15 +2,14 @@ namespace App\Domains\Bundle\Models; +use App\Domains\Shared\Contracts\Buyable; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Casts\Attribute; - -use App\Domains\Shared\Contracts\Stockable; #[Fillable([ 'tenant_codigo', @@ -18,7 +17,7 @@ use App\Domains\Shared\Contracts\Stockable; 'descripcion', 'precio', ])] -class Bundle extends Model implements Stockable +class Bundle extends Model implements Buyable { use HasFactory; @@ -49,36 +48,43 @@ class Bundle extends Model implements Stockable return $this->hasMany(BundleItem::class, 'bundle_id'); } - public function incrementRealStock(int $amount): void + public function getPrice(): float { - if ($amount < 0) { - throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); - } - - foreach ($this->items as $item) { - $item->variant->incrementRealStock($amount * $item->cantidad); - } + return (float) $this->precio; } - public function decrementRealStock(int $amount): void + public function getName(): string { - if ($amount < 0) { - throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.'); - } - - foreach ($this->items as $item) { - $item->variant->decrementRealStock($amount * $item->cantidad); - } + return $this->nombre ?? 'Bundle'; } - public function incrementReservedStock(int $amount): void + public function availableQuantity(): ?int + { + if ($this->items->isEmpty()) { + return 0; + } + + $availableQuantities = []; + + foreach ($this->items as $item) { + $variantQuantity = $item->variant?->availableQuantity(); + + if ($variantQuantity !== null) { + $availableQuantities[] = intdiv($variantQuantity, $item->cantidad); + } + } + + return $availableQuantities === [] ? null : min($availableQuantities); + } + + public function reserveStock(int $amount): void { if ($amount < 0) { - throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); + throw new \InvalidArgumentException('El monto a reservar debe ser positivo.'); } foreach ($this->items as $item) { - $item->variant->incrementReservedStock($amount * $item->cantidad); + $item->variant->reserveStock($amount * $item->cantidad); } } @@ -93,43 +99,28 @@ class Bundle extends Model implements Stockable } } - public function confirmReservedStock(int $amount): void + public function buy(int $amount): void { if ($amount < 0) { - throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.'); + throw new \InvalidArgumentException('El monto a comprar debe ser positivo.'); } foreach ($this->items as $item) { - $item->variant->confirmReservedStock($amount * $item->cantidad); + $item->variant->buy($amount * $item->cantidad); } } public function validateStock(): void { - if ($this->stock_tecnico <= 0) { - throw new \InvalidArgumentException('El bundle no tiene stock técnico disponible.'); + $availableQuantity = $this->availableQuantity(); + + if ($availableQuantity !== null && $availableQuantity <= 0) { + throw new \InvalidArgumentException('El bundle no tiene stock tecnico disponible.'); } } protected function stockTecnico(): Attribute { - return Attribute::get(function () { - if ($this->items->isEmpty()) { - return 0; - } - - $minStock = PHP_INT_MAX; - foreach ($this->items as $item) { - // Ensure variant is loaded - if ($item->variant) { - $itemStock = floor($item->variant->stock_tecnico / $item->cantidad); - if ($itemStock < $minStock) { - $minStock = $itemStock; - } - } - } - - return $minStock === PHP_INT_MAX ? 0 : (int) $minStock; - }); + return Attribute::get(fn (): ?int => $this->availableQuantity()); } } diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 3bd2f7b..e9faae7 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -29,7 +29,8 @@ class CartController extends Controller $result = $this->cartService->addItem( $tenant, $request, - (int) $request->validated('product_variant_id'), + $request->validated('buyable_type'), + (int) $request->validated('buyable_id'), (int) $request->validated('cantidad'), ); @@ -47,22 +48,24 @@ class CartController extends Controller public function updateItemQuantity( UpdateCartItemQuantityRequest $request, Tenant $tenant, - ProductVariant $productVariant, + string $buyableType, + int $buyableId, ): CartResource { return CartResource::make( $this->cartService->updateItemQuantity( $tenant, $request, - $productVariant->getKey(), + $buyableType, + $buyableId, (int) $request->validated('cantidad'), ) )->additional(['message' => 'Cantidad de producto actualizada.']); } - public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource + public function removeItem(Request $request, Tenant $tenant, string $buyableType, int $buyableId): CartResource { return CartResource::make( - $this->cartService->removeItem($tenant, $request, $productVariant->getKey()) + $this->cartService->removeItem($tenant, $request, $buyableType, $buyableId) )->additional(['message' => 'Producto eliminado del carrito.']); } } diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index b726e22..4455bc8 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -3,7 +3,9 @@ namespace App\Domains\Cart\Models; use App\Domains\Auth\Models\User; +use App\Domains\Bundle\Models\Bundle; use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Shared\Contracts\Buyable; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -63,15 +65,15 @@ class Cart extends Model { $items = $this->relationLoaded('items') ? $this->getRelation('items') - : $this->items()->with('variant.product')->get(); + : $this->items()->with('buyable')->get(); return (float) $items->reduce( - fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad), + fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad), 0.0, ); } - public function addItem(int $productVariantId, int $quantity): CartItem + public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ @@ -79,24 +81,28 @@ class Cart extends Model ]); } - return DB::transaction(function () use ($productVariantId, $quantity): CartItem { - $variant = $this->resolveScopedVariant($productVariantId, true); + return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem { + $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true); + $canonicalType = $buyable::class; + $availableQuantity = $buyable->availableQuantity(); - if ($variant->tracksInventory() && $variant->availableQuantity() < $quantity) { + if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ - 'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->availableQuantity()}.", + 'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.", ]); } /** @var CartItem|null $item */ $item = $this->items() - ->where('producto_variante_id', $variant->getKey()) + ->where('buyable_type', $canonicalType) + ->where('buyable_id', $buyable->getKey()) ->lockForUpdate() ->first(); if ($item === null) { $item = $this->items()->create([ - 'producto_variante_id' => $variant->getKey(), + 'buyable_type' => $canonicalType, + 'buyable_id' => $buyable->getKey(), 'cantidad' => $quantity, ]); } else { @@ -104,13 +110,13 @@ class Cart extends Model $item->save(); } - $variant->reserveStock($quantity); + $buyable->reserveStock($quantity); return $item->fresh(); }); } - public function updateItem(int $productVariantId, int $quantity): CartItem + public function updateItem(string $buyableType, int $buyableId, int $quantity): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ @@ -118,18 +124,22 @@ class Cart extends Model ]); } - return DB::transaction(function () use ($productVariantId, $quantity): CartItem { + return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem { + $canonicalType = $this->resolveBuyableClass($buyableType); + /** @var CartItem $item */ $item = $this->items() - ->where('producto_variante_id', $productVariantId) + ->where('buyable_type', $canonicalType) + ->where('buyable_id', $buyableId) ->lockForUpdate() ->firstOrFail(); - $variant = $this->resolveScopedVariant($productVariantId, true); + $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true); $delta = $quantity - $item->cantidad; + $availableQuantity = $buyable->availableQuantity(); - if ($delta > 0 && $variant->tracksInventory() && $variant->availableQuantity() < $delta) { - $maxAvailable = $variant->availableQuantity() + $item->cantidad; + if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { + $maxAvailable = $availableQuantity + $item->cantidad; throw ValidationException::withMessages([ 'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.", ]); @@ -139,49 +149,68 @@ class Cart extends Model $item->save(); if ($delta > 0) { - $variant->reserveStock($delta); + $buyable->reserveStock($delta); } if ($delta < 0) { - $variant->decrementReservedStock(abs($delta)); + $buyable->decrementReservedStock(abs($delta)); } return $item->fresh(); }); } - public function removeItem(int $productVariantId): void + public function removeItem(string $buyableType, int $buyableId): void { - DB::transaction(function () use ($productVariantId): void { + DB::transaction(function () use ($buyableType, $buyableId): void { + $canonicalType = $this->resolveBuyableClass($buyableType); + /** @var CartItem $item */ $item = $this->items() - ->where('producto_variante_id', $productVariantId) + ->where('buyable_type', $canonicalType) + ->where('buyable_id', $buyableId) ->lockForUpdate() ->firstOrFail(); - $variant = $this->resolveScopedVariant($productVariantId, true); - $variant->decrementReservedStock($item->cantidad); + $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true); + $buyable->decrementReservedStock($item->cantidad); $item->delete(); }); } - protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant + protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable { - $query = ProductVariant::query() - ->whereKey($productVariantId) - ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo)); + $buyableClass = $this->resolveBuyableClass($buyableType); + + if ($buyableClass === ProductVariant::class) { + $query = ProductVariant::query() + ->whereKey($buyableId) + ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo)); + } else { + $query = Bundle::query() + ->whereKey($buyableId) + ->where('tenant_codigo', $this->tenant_codigo); + } if ($lockForUpdate) { $query->lockForUpdate(); } - /** @var ProductVariant|null $variant */ - $variant = $query->first(); + $buyable = $query->first(); - if ($variant === null) { - throw new NotFoundHttpException('Product variant not found for tenant.'); + if ($buyable === null) { + throw new NotFoundHttpException('Buyable not found for tenant.'); } - return $variant; + return $buyable; + } + + protected function resolveBuyableClass(string $buyableType): string + { + return match ($buyableType) { + 'variant', ProductVariant::class => ProductVariant::class, + 'bundle', Bundle::class => Bundle::class, + default => throw new \InvalidArgumentException('Invalid buyable type'), + }; } } diff --git a/app/Domains/Cart/Requests/AddCartItemRequest.php b/app/Domains/Cart/Requests/AddCartItemRequest.php index 9bc0bf6..0c7a154 100644 --- a/app/Domains/Cart/Requests/AddCartItemRequest.php +++ b/app/Domains/Cart/Requests/AddCartItemRequest.php @@ -17,7 +17,8 @@ class AddCartItemRequest extends FormRequest public function rules(): array { return [ - 'product_variant_id' => ['required', 'integer'], + 'buyable_type' => ['required', 'string', \Illuminate\Validation\Rule::in(['variant', 'bundle'])], + 'buyable_id' => ['required', 'integer'], 'cantidad' => ['required', 'integer', 'min:1'], ]; } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index c768d43..9f8214b 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -15,29 +15,15 @@ class CartItemResource extends JsonResource */ public function toArray(Request $request): array { - $variant = $this->variant; - $product = $variant?->product; - - $attributesText = ''; - if ($variant && $variant->relationLoaded('definitions')) { - $attributesText = $variant->definitions - ->map(function ($definition) { - $attrName = $definition->productAttribute?->attribute?->nombre; - $value = $definition->value; - return $attrName ? "{$attrName}: {$value}" : $value; - }) - ->filter() - ->implode(', '); - } - - $productName = $product?->nombre; - if ($productName && $attributesText !== '') { - $productName .= " ({$attributesText})"; - } - + /** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */ + $buyable = $this->buyable; + + $productName = $buyable?->getName(); + $precio = $buyable?->getPrice(); + $imageUrl = null; - if ($variant && $variant->relationLoaded('attachments')) { - $firstAttachment = $variant->attachments->first(); + if ($this->buyable_type === 'variant' && $buyable && $buyable->relationLoaded('attachments')) { + $firstAttachment = $buyable->attachments->first(); if ($firstAttachment) { $imageUrl = $firstAttachment->getTemporaryUrl(1440); } @@ -46,10 +32,10 @@ class CartItemResource extends JsonResource return [ 'id' => $this->id, 'cantidad' => $this->cantidad, - 'precio_unitario' => $this->formatMoney($product?->precio), - 'product_id' => $product?->id, - 'product_variant_id' => $this->producto_variante_id, - 'product' => $product === null ? null : [ + 'precio_unitario' => $this->formatMoney($precio), + 'buyable_type' => $this->buyable_type, + 'buyable_id' => $this->buyable_id, + 'product' => $buyable === null ? null : [ 'nombre' => $productName, 'imagen' => $imageUrl, ], diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index a814fcf..417579a 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -32,12 +32,12 @@ class CartService /** * @return array{cart: Cart, guest_token: ?string} */ - public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array + public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array { $resolvedIdentity = $this->resolveIdentity($request, true); $identity = $resolvedIdentity['identity']; $cart = $this->findOrCreateCart($tenant, $identity); - $cart->addItem($productVariantId, $quantity); + $cart->addItem($buyableType, $buyableId, $quantity); return [ 'cart' => $this->loadCart($cart), @@ -45,20 +45,20 @@ class CartService ]; } - public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart + public function updateItemQuantity(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): Cart { $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); - $cart->updateItem($productVariantId, $quantity); + $cart->updateItem($buyableType, $buyableId, $quantity); return $this->loadCart($cart); } - public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart + public function removeItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId): Cart { $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); - $cart->removeItem($productVariantId); + $cart->removeItem($buyableType, $buyableId); return $this->loadCart($cart); } diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 910a331..b127a80 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -8,6 +8,6 @@ Route::prefix('tenants/{tenant:codigo}') ->group(function (): void { Route::get('cart', [CartController::class, 'show']); Route::post('cart/items', [CartController::class, 'addItem']); - Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']); - Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']); + Route::patch('cart/items/{buyableType}/{buyableId}', [CartController::class, 'updateItemQuantity']); + Route::delete('cart/items/{buyableType}/{buyableId}', [CartController::class, 'removeItem']); }); diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php index 1f809db..d46d62c 100644 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ b/app/Domains/Catalog/Models/ProductVariant.php @@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Shared\Contracts\Buyable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,10 +13,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Casts\Attribute; - -use App\Domains\Shared\Contracts\Stockable; - #[Fillable([ 'producto_id', 'inventory_policy', @@ -27,7 +24,7 @@ use App\Domains\Shared\Contracts\Stockable; 'minimum_use_date', 'maximum_use_date', ])] -class ProductVariant extends Model implements Stockable +class ProductVariant extends Model implements Buyable { use HasFactory; @@ -91,6 +88,28 @@ class ProductVariant extends Model implements Stockable return ! $this->tracksInventory() || $this->availableQuantity() > 0; } + public function getPrice(): float + { + return (float) ($this->product->precio ?? 0.0); + } + + public function getName(): string + { + $name = $this->product?->nombre ?? 'Producto'; + + if ($this->relationLoaded('definitions') && $this->definitions->isNotEmpty()) { + $definitions = $this->definitions->map(function ($def) { + return $def->value ?? $def->productAttribute?->attribute?->nombre; + })->filter()->implode(', '); + + if ($definitions !== '') { + $name .= " ({$definitions})"; + } + } + + return $name; + } + public function reserveStock(int $amount): void { if ($amount < 0) { diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index d26a5bc..54a9e6a 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -17,26 +17,30 @@ class PurchaseItemResource extends JsonResource */ public function toArray(Request $request): array { - $variant = $this->variant; - $product = $variant?->product; + /** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */ + $buyable = $this->buyable; $quantity = (int) ($this->cantidad ?? 0); $unitPrice = $this->resolveUnitPrice(); $lineTotal = $this->resolveLineTotal($unitPrice, $quantity); + $imageUrl = null; + $attributes = []; + if ($this->buyable_type === 'variant' && $buyable) { + $imageUrl = $this->resolveImageUrl($buyable); + $attributes = $this->resolveAttributes($buyable); + } + return [ 'id' => $this->id, 'quantity' => $quantity, 'unit_price' => $this->formatMoney($unitPrice), 'line_total' => $this->formatMoney($lineTotal), - 'product' => $product === null ? null : [ - 'id' => $product->id, - 'nombre' => $product->nombre, - 'slug' => $product->slug, - 'imagen' => $this->resolveImageUrl(), - ], - 'variant' => $variant === null ? null : [ - 'id' => $variant->id, - 'attributes' => $this->resolveAttributes(), + 'buyable_type' => $this->buyable_type, + 'buyable_id' => $this->buyable_id, + 'item_details' => $buyable === null ? null : [ + 'nombre' => $buyable->getName(), + 'imagen' => $imageUrl, + 'attributes' => $attributes, ], ]; } @@ -48,7 +52,7 @@ class PurchaseItemResource extends JsonResource } if ($this->resource instanceof CartItem) { - return (float) ($this->variant?->product?->precio ?? 0); + return (float) ($this->buyable?->getPrice() ?? 0); } return 0.0; @@ -63,15 +67,13 @@ class PurchaseItemResource extends JsonResource return $unitPrice * $quantity; } - protected function resolveImageUrl(): ?string + protected function resolveImageUrl($buyable): ?string { - $variant = $this->variant; - - if ($variant === null || ! $variant->relationLoaded('attachments')) { + if (! $buyable->relationLoaded('attachments')) { return null; } - $attachment = $variant->attachments->first(); + $attachment = $buyable->attachments->first(); if ($attachment === null) { return null; @@ -83,15 +85,13 @@ class PurchaseItemResource extends JsonResource /** * @return array */ - protected function resolveAttributes(): array + protected function resolveAttributes($buyable): array { - $variant = $this->variant; - - if ($variant === null || ! $variant->relationLoaded('definitions')) { + if (! $buyable->relationLoaded('definitions')) { return []; } - return $variant->definitions + return $buyable->definitions ->map(function ($definition): array { return [ 'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''), diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 3dca897..e2910bb 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Bundle\Models\Bundle; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; @@ -29,7 +30,7 @@ class CheckoutService ]); } - $cartItems->load('variant.product'); + $cartItems->load('buyable'); $cart->setRelation('items', $cartItems); $totalAmount = $cart->getTotalAmount(); @@ -63,7 +64,7 @@ class CheckoutService $purchase->save(); } - return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); + return $purchase->load(['items.buyable']); }); } @@ -82,7 +83,7 @@ class CheckoutService } if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) { - return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); + return $purchase->load(['items.buyable']); } $purchase->update([ @@ -90,7 +91,7 @@ class CheckoutService 'total' => $purchase->calculateCurrentTotalAmount(), ]); - return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); + return $purchase->load(['items.buyable']); }); } @@ -123,42 +124,37 @@ class CheckoutService ]); } - $cartItems->load('variant.product'); - $variants = $this->resolveTenantVariants($purchase->tenant, $cartItems); - $purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants); + $cartItems->load('buyable'); + $this->verifyTenantBuyables($purchase->tenant, $cartItems); + $purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems); $purchase->items()->createMany($purchaseItemsPayload); - $this->completeCartConversion($cart, $cartItems, $variants); + $this->completeCartConversion($cart, $cartItems); }); } - /** - * @return Collection - */ - protected function resolveTenantVariants(Tenant $tenant, Collection $cartItems): Collection + protected function verifyTenantBuyables(Tenant $tenant, Collection $cartItems): void { - $variantIds = $cartItems - ->pluck('producto_variante_id') - ->map(static fn (mixed $id): int => (int) $id) - ->unique() - ->values(); + foreach ($cartItems as $item) { + $buyable = $item->buyable; + if ($buyable === null) { + throw ValidationException::withMessages([ + 'cart_id' => 'One or more buyables could not be loaded.', + ]); + } - /** @var Collection $variants */ - $variants = ProductVariant::query() - ->with('product') - ->whereIn('id', $variantIds) - ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo)) - ->lockForUpdate() - ->get() - ->keyBy('id'); + if ($item->buyable_type === ProductVariant::class && $buyable->product->tenant_codigo !== $tenant->codigo) { + throw ValidationException::withMessages([ + 'cart_id' => 'One or more product variants do not belong to the tenant.', + ]); + } - if ($variants->count() !== $variantIds->count()) { - throw ValidationException::withMessages([ - 'cart_id' => 'One or more product variants do not belong to the tenant.', - ]); + if ($item->buyable_type === Bundle::class && $buyable->tenant_codigo !== $tenant->codigo) { + throw ValidationException::withMessages([ + 'cart_id' => 'One or more bundles do not belong to the tenant.', + ]); + } } - - return $variants; } /** @@ -187,20 +183,19 @@ class CheckoutService /** * @param Collection $cartItems - * @param Collection $variants * @return array> */ - protected function buildPurchaseItemsPayload(Collection $cartItems, Collection $variants): array + protected function buildPurchaseItemsPayload(Collection $cartItems): array { return $cartItems - ->map(function (CartItem $item) use ($variants): array { - /** @var ProductVariant $variant */ - $variant = $variants->get((int) $item['producto_variante_id']); + ->map(function (CartItem $item): array { + $buyable = $item->buyable; $quantity = (int) $item['cantidad']; - $unitPrice = (float) ($variant->product?->precio ?? 0); + $unitPrice = $buyable?->getPrice() ?? 0; return [ - 'producto_variante_id' => $variant->getKey(), + 'buyable_type' => $item->buyable_type, + 'buyable_id' => $item->buyable_id, 'cantidad' => $quantity, 'precio_unitario' => $unitPrice, 'discount_total' => null, @@ -213,17 +208,15 @@ class CheckoutService /** * @param Collection $cartItems - * @param Collection $variants */ - protected function completeCartConversion(Cart $cart, Collection $cartItems, Collection $variants): void + protected function completeCartConversion(Cart $cart, Collection $cartItems): void { foreach ($cartItems as $item) { - /** @var ProductVariant $variant */ - $variant = $variants->get((int) $item->producto_variante_id); + $buyable = $item->buyable; $quantity = (int) $item->cantidad; try { - $variant->buy($quantity); + $buyable->buy($quantity); } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'cart_id' => 'The selected cart has inconsistent stock state.', diff --git a/app/Domains/Shared/Contracts/Buyable.php b/app/Domains/Shared/Contracts/Buyable.php new file mode 100644 index 0000000..d33c060 --- /dev/null +++ b/app/Domains/Shared/Contracts/Buyable.php @@ -0,0 +1,20 @@ +