feat: refactor cart and bundle handling to support Buyable interface for improved flexibility
This commit is contained in:
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Domains\Bundle\Models;
|
namespace App\Domains\Bundle\Models;
|
||||||
|
|
||||||
|
use App\Domains\Shared\Contracts\Buyable;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
|
|
||||||
use App\Domains\Shared\Contracts\Stockable;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
@@ -18,7 +17,7 @@ use App\Domains\Shared\Contracts\Stockable;
|
|||||||
'descripcion',
|
'descripcion',
|
||||||
'precio',
|
'precio',
|
||||||
])]
|
])]
|
||||||
class Bundle extends Model implements Stockable
|
class Bundle extends Model implements Buyable
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
@@ -49,36 +48,43 @@ class Bundle extends Model implements Stockable
|
|||||||
return $this->hasMany(BundleItem::class, 'bundle_id');
|
return $this->hasMany(BundleItem::class, 'bundle_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function incrementRealStock(int $amount): void
|
public function getPrice(): float
|
||||||
{
|
{
|
||||||
if ($amount < 0) {
|
return (float) $this->precio;
|
||||||
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$item->variant->incrementRealStock($amount * $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function decrementRealStock(int $amount): void
|
public function getName(): string
|
||||||
{
|
{
|
||||||
if ($amount < 0) {
|
return $this->nombre ?? 'Bundle';
|
||||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$item->variant->decrementRealStock($amount * $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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) {
|
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) {
|
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) {
|
foreach ($this->items as $item) {
|
||||||
$item->variant->confirmReservedStock($amount * $item->cantidad);
|
$item->variant->buy($amount * $item->cantidad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateStock(): void
|
public function validateStock(): void
|
||||||
{
|
{
|
||||||
if ($this->stock_tecnico <= 0) {
|
$availableQuantity = $this->availableQuantity();
|
||||||
throw new \InvalidArgumentException('El bundle no tiene stock técnico disponible.');
|
|
||||||
|
if ($availableQuantity !== null && $availableQuantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('El bundle no tiene stock tecnico disponible.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function stockTecnico(): Attribute
|
protected function stockTecnico(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(function () {
|
return Attribute::get(fn (): ?int => $this->availableQuantity());
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ class CartController extends Controller
|
|||||||
$result = $this->cartService->addItem(
|
$result = $this->cartService->addItem(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request,
|
$request,
|
||||||
(int) $request->validated('product_variant_id'),
|
$request->validated('buyable_type'),
|
||||||
|
(int) $request->validated('buyable_id'),
|
||||||
(int) $request->validated('cantidad'),
|
(int) $request->validated('cantidad'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,22 +48,24 @@ class CartController extends Controller
|
|||||||
public function updateItemQuantity(
|
public function updateItemQuantity(
|
||||||
UpdateCartItemQuantityRequest $request,
|
UpdateCartItemQuantityRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
ProductVariant $productVariant,
|
string $buyableType,
|
||||||
|
int $buyableId,
|
||||||
): CartResource {
|
): CartResource {
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
$this->cartService->updateItemQuantity(
|
$this->cartService->updateItemQuantity(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request,
|
$request,
|
||||||
$productVariant->getKey(),
|
$buyableType,
|
||||||
|
$buyableId,
|
||||||
(int) $request->validated('cantidad'),
|
(int) $request->validated('cantidad'),
|
||||||
)
|
)
|
||||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
)->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(
|
return CartResource::make(
|
||||||
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
$this->cartService->removeItem($tenant, $request, $buyableType, $buyableId)
|
||||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Bundle\Models\Bundle;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Shared\Contracts\Buyable;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -63,15 +65,15 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
? $this->getRelation('items')
|
? $this->getRelation('items')
|
||||||
: $this->items()->with('variant.product')->get();
|
: $this->items()->with('buyable')->get();
|
||||||
|
|
||||||
return (float) $items->reduce(
|
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,
|
0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addItem(int $productVariantId, int $quantity): CartItem
|
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
|
||||||
{
|
{
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -79,24 +81,28 @@ class Cart extends Model
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
|
||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$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([
|
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 */
|
/** @var CartItem|null $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('producto_variante_id', $variant->getKey())
|
->where('buyable_type', $canonicalType)
|
||||||
|
->where('buyable_id', $buyable->getKey())
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
$item = $this->items()->create([
|
$item = $this->items()->create([
|
||||||
'producto_variante_id' => $variant->getKey(),
|
'buyable_type' => $canonicalType,
|
||||||
|
'buyable_id' => $buyable->getKey(),
|
||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
@@ -104,13 +110,13 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$variant->reserveStock($quantity);
|
$buyable->reserveStock($quantity);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItem(int $productVariantId, int $quantity): CartItem
|
public function updateItem(string $buyableType, int $buyableId, int $quantity): CartItem
|
||||||
{
|
{
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
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 */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('producto_variante_id', $productVariantId)
|
->where('buyable_type', $canonicalType)
|
||||||
|
->where('buyable_id', $buyableId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
|
||||||
$delta = $quantity - $item->cantidad;
|
$delta = $quantity - $item->cantidad;
|
||||||
|
$availableQuantity = $buyable->availableQuantity();
|
||||||
|
|
||||||
if ($delta > 0 && $variant->tracksInventory() && $variant->availableQuantity() < $delta) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $variant->availableQuantity() + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||||
]);
|
]);
|
||||||
@@ -139,49 +149,68 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$variant->reserveStock($delta);
|
$buyable->reserveStock($delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$variant->decrementReservedStock(abs($delta));
|
$buyable->decrementReservedStock(abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item->fresh();
|
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 */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('producto_variante_id', $productVariantId)
|
->where('buyable_type', $canonicalType)
|
||||||
|
->where('buyable_id', $buyableId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
|
||||||
$variant->decrementReservedStock($item->cantidad);
|
$buyable->decrementReservedStock($item->cantidad);
|
||||||
$item->delete();
|
$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()
|
$buyableClass = $this->resolveBuyableClass($buyableType);
|
||||||
->whereKey($productVariantId)
|
|
||||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
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) {
|
if ($lockForUpdate) {
|
||||||
$query->lockForUpdate();
|
$query->lockForUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var ProductVariant|null $variant */
|
$buyable = $query->first();
|
||||||
$variant = $query->first();
|
|
||||||
|
|
||||||
if ($variant === null) {
|
if ($buyable === null) {
|
||||||
throw new NotFoundHttpException('Product variant not found for tenant.');
|
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'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class AddCartItemRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
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'],
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,29 +15,15 @@ class CartItemResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$variant = $this->variant;
|
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
|
||||||
$product = $variant?->product;
|
$buyable = $this->buyable;
|
||||||
|
|
||||||
$attributesText = '';
|
$productName = $buyable?->getName();
|
||||||
if ($variant && $variant->relationLoaded('definitions')) {
|
$precio = $buyable?->getPrice();
|
||||||
$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})";
|
|
||||||
}
|
|
||||||
|
|
||||||
$imageUrl = null;
|
$imageUrl = null;
|
||||||
if ($variant && $variant->relationLoaded('attachments')) {
|
if ($this->buyable_type === 'variant' && $buyable && $buyable->relationLoaded('attachments')) {
|
||||||
$firstAttachment = $variant->attachments->first();
|
$firstAttachment = $buyable->attachments->first();
|
||||||
if ($firstAttachment) {
|
if ($firstAttachment) {
|
||||||
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
|
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
@@ -46,10 +32,10 @@ class CartItemResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'cantidad' => $this->cantidad,
|
'cantidad' => $this->cantidad,
|
||||||
'precio_unitario' => $this->formatMoney($product?->precio),
|
'precio_unitario' => $this->formatMoney($precio),
|
||||||
'product_id' => $product?->id,
|
'buyable_type' => $this->buyable_type,
|
||||||
'product_variant_id' => $this->producto_variante_id,
|
'buyable_id' => $this->buyable_id,
|
||||||
'product' => $product === null ? null : [
|
'product' => $buyable === null ? null : [
|
||||||
'nombre' => $productName,
|
'nombre' => $productName,
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ class CartService
|
|||||||
/**
|
/**
|
||||||
* @return array{cart: Cart, guest_token: ?string}
|
* @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);
|
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||||
$identity = $resolvedIdentity['identity'];
|
$identity = $resolvedIdentity['identity'];
|
||||||
$cart = $this->findOrCreateCart($tenant, $identity);
|
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||||
$cart->addItem($productVariantId, $quantity);
|
$cart->addItem($buyableType, $buyableId, $quantity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cart' => $this->loadCart($cart),
|
'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);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->updateItem($productVariantId, $quantity);
|
$cart->updateItem($buyableType, $buyableId, $quantity);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
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);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->removeItem($productVariantId);
|
$cart->removeItem($buyableType, $buyableId);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ Route::prefix('tenants/{tenant:codigo}')
|
|||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('cart', [CartController::class, 'show']);
|
Route::get('cart', [CartController::class, 'show']);
|
||||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||||
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
|
Route::patch('cart/items/{buyableType}/{buyableId}', [CartController::class, 'updateItemQuantity']);
|
||||||
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
|
Route::delete('cart/items/{buyableType}/{buyableId}', [CartController::class, 'removeItem']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Models;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Shared\Contracts\Buyable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
|
|
||||||
use App\Domains\Shared\Contracts\Stockable;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'producto_id',
|
'producto_id',
|
||||||
'inventory_policy',
|
'inventory_policy',
|
||||||
@@ -27,7 +24,7 @@ use App\Domains\Shared\Contracts\Stockable;
|
|||||||
'minimum_use_date',
|
'minimum_use_date',
|
||||||
'maximum_use_date',
|
'maximum_use_date',
|
||||||
])]
|
])]
|
||||||
class ProductVariant extends Model implements Stockable
|
class ProductVariant extends Model implements Buyable
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
@@ -91,6 +88,28 @@ class ProductVariant extends Model implements Stockable
|
|||||||
return ! $this->tracksInventory() || $this->availableQuantity() > 0;
|
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
|
public function reserveStock(int $amount): void
|
||||||
{
|
{
|
||||||
if ($amount < 0) {
|
if ($amount < 0) {
|
||||||
|
|||||||
@@ -17,26 +17,30 @@ class PurchaseItemResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$variant = $this->variant;
|
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
|
||||||
$product = $variant?->product;
|
$buyable = $this->buyable;
|
||||||
$quantity = (int) ($this->cantidad ?? 0);
|
$quantity = (int) ($this->cantidad ?? 0);
|
||||||
$unitPrice = $this->resolveUnitPrice();
|
$unitPrice = $this->resolveUnitPrice();
|
||||||
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
|
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
|
||||||
|
|
||||||
|
$imageUrl = null;
|
||||||
|
$attributes = [];
|
||||||
|
if ($this->buyable_type === 'variant' && $buyable) {
|
||||||
|
$imageUrl = $this->resolveImageUrl($buyable);
|
||||||
|
$attributes = $this->resolveAttributes($buyable);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $this->formatMoney($unitPrice),
|
'unit_price' => $this->formatMoney($unitPrice),
|
||||||
'line_total' => $this->formatMoney($lineTotal),
|
'line_total' => $this->formatMoney($lineTotal),
|
||||||
'product' => $product === null ? null : [
|
'buyable_type' => $this->buyable_type,
|
||||||
'id' => $product->id,
|
'buyable_id' => $this->buyable_id,
|
||||||
'nombre' => $product->nombre,
|
'item_details' => $buyable === null ? null : [
|
||||||
'slug' => $product->slug,
|
'nombre' => $buyable->getName(),
|
||||||
'imagen' => $this->resolveImageUrl(),
|
'imagen' => $imageUrl,
|
||||||
],
|
'attributes' => $attributes,
|
||||||
'variant' => $variant === null ? null : [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'attributes' => $this->resolveAttributes(),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,7 @@ class PurchaseItemResource extends JsonResource
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->resource instanceof CartItem) {
|
if ($this->resource instanceof CartItem) {
|
||||||
return (float) ($this->variant?->product?->precio ?? 0);
|
return (float) ($this->buyable?->getPrice() ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -63,15 +67,13 @@ class PurchaseItemResource extends JsonResource
|
|||||||
return $unitPrice * $quantity;
|
return $unitPrice * $quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveImageUrl(): ?string
|
protected function resolveImageUrl($buyable): ?string
|
||||||
{
|
{
|
||||||
$variant = $this->variant;
|
if (! $buyable->relationLoaded('attachments')) {
|
||||||
|
|
||||||
if ($variant === null || ! $variant->relationLoaded('attachments')) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachment = $variant->attachments->first();
|
$attachment = $buyable->attachments->first();
|
||||||
|
|
||||||
if ($attachment === null) {
|
if ($attachment === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,15 +85,13 @@ class PurchaseItemResource extends JsonResource
|
|||||||
/**
|
/**
|
||||||
* @return array<int, array{name: string, value: mixed}>
|
* @return array<int, array{name: string, value: mixed}>
|
||||||
*/
|
*/
|
||||||
protected function resolveAttributes(): array
|
protected function resolveAttributes($buyable): array
|
||||||
{
|
{
|
||||||
$variant = $this->variant;
|
if (! $buyable->relationLoaded('definitions')) {
|
||||||
|
|
||||||
if ($variant === null || ! $variant->relationLoaded('definitions')) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $variant->definitions
|
return $buyable->definitions
|
||||||
->map(function ($definition): array {
|
->map(function ($definition): array {
|
||||||
return [
|
return [
|
||||||
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
|
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Bundle\Models\Bundle;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -29,7 +30,7 @@ class CheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cartItems->load('variant.product');
|
$cartItems->load('buyable');
|
||||||
$cart->setRelation('items', $cartItems);
|
$cart->setRelation('items', $cartItems);
|
||||||
$totalAmount = $cart->getTotalAmount();
|
$totalAmount = $cart->getTotalAmount();
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ class CheckoutService
|
|||||||
$purchase->save();
|
$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)) {
|
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([
|
$purchase->update([
|
||||||
@@ -90,7 +91,7 @@ class CheckoutService
|
|||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'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');
|
$cartItems->load('buyable');
|
||||||
$variants = $this->resolveTenantVariants($purchase->tenant, $cartItems);
|
$this->verifyTenantBuyables($purchase->tenant, $cartItems);
|
||||||
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants);
|
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems);
|
||||||
|
|
||||||
$purchase->items()->createMany($purchaseItemsPayload);
|
$purchase->items()->createMany($purchaseItemsPayload);
|
||||||
$this->completeCartConversion($cart, $cartItems, $variants);
|
$this->completeCartConversion($cart, $cartItems);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function verifyTenantBuyables(Tenant $tenant, Collection $cartItems): void
|
||||||
* @return Collection<int, ProductVariant>
|
|
||||||
*/
|
|
||||||
protected function resolveTenantVariants(Tenant $tenant, Collection $cartItems): Collection
|
|
||||||
{
|
{
|
||||||
$variantIds = $cartItems
|
foreach ($cartItems as $item) {
|
||||||
->pluck('producto_variante_id')
|
$buyable = $item->buyable;
|
||||||
->map(static fn (mixed $id): int => (int) $id)
|
if ($buyable === null) {
|
||||||
->unique()
|
throw ValidationException::withMessages([
|
||||||
->values();
|
'cart_id' => 'One or more buyables could not be loaded.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Collection<int, ProductVariant> $variants */
|
if ($item->buyable_type === ProductVariant::class && $buyable->product->tenant_codigo !== $tenant->codigo) {
|
||||||
$variants = ProductVariant::query()
|
throw ValidationException::withMessages([
|
||||||
->with('product')
|
'cart_id' => 'One or more product variants do not belong to the tenant.',
|
||||||
->whereIn('id', $variantIds)
|
]);
|
||||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
}
|
||||||
->lockForUpdate()
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
if ($variants->count() !== $variantIds->count()) {
|
if ($item->buyable_type === Bundle::class && $buyable->tenant_codigo !== $tenant->codigo) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'One or more product variants do not belong to the tenant.',
|
'cart_id' => 'One or more bundles do not belong to the tenant.',
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $variants;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,20 +183,19 @@ class CheckoutService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, CartItem> $cartItems
|
* @param Collection<int, CartItem> $cartItems
|
||||||
* @param Collection<int, ProductVariant> $variants
|
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
protected function buildPurchaseItemsPayload(Collection $cartItems, Collection $variants): array
|
protected function buildPurchaseItemsPayload(Collection $cartItems): array
|
||||||
{
|
{
|
||||||
return $cartItems
|
return $cartItems
|
||||||
->map(function (CartItem $item) use ($variants): array {
|
->map(function (CartItem $item): array {
|
||||||
/** @var ProductVariant $variant */
|
$buyable = $item->buyable;
|
||||||
$variant = $variants->get((int) $item['producto_variante_id']);
|
|
||||||
$quantity = (int) $item['cantidad'];
|
$quantity = (int) $item['cantidad'];
|
||||||
$unitPrice = (float) ($variant->product?->precio ?? 0);
|
$unitPrice = $buyable?->getPrice() ?? 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'producto_variante_id' => $variant->getKey(),
|
'buyable_type' => $item->buyable_type,
|
||||||
|
'buyable_id' => $item->buyable_id,
|
||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
'precio_unitario' => $unitPrice,
|
'precio_unitario' => $unitPrice,
|
||||||
'discount_total' => null,
|
'discount_total' => null,
|
||||||
@@ -213,17 +208,15 @@ class CheckoutService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, CartItem> $cartItems
|
* @param Collection<int, CartItem> $cartItems
|
||||||
* @param Collection<int, ProductVariant> $variants
|
|
||||||
*/
|
*/
|
||||||
protected function completeCartConversion(Cart $cart, Collection $cartItems, Collection $variants): void
|
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
|
||||||
{
|
{
|
||||||
foreach ($cartItems as $item) {
|
foreach ($cartItems as $item) {
|
||||||
/** @var ProductVariant $variant */
|
$buyable = $item->buyable;
|
||||||
$variant = $variants->get((int) $item->producto_variante_id);
|
|
||||||
$quantity = (int) $item->cantidad;
|
$quantity = (int) $item->cantidad;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$variant->buy($quantity);
|
$buyable->buy($quantity);
|
||||||
} catch (\InvalidArgumentException $exception) {
|
} catch (\InvalidArgumentException $exception) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The selected cart has inconsistent stock state.',
|
'cart_id' => 'The selected cart has inconsistent stock state.',
|
||||||
|
|||||||
20
app/Domains/Shared/Contracts/Buyable.php
Normal file
20
app/Domains/Shared/Contracts/Buyable.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Shared\Contracts;
|
||||||
|
|
||||||
|
interface Buyable
|
||||||
|
{
|
||||||
|
public function getPrice(): float;
|
||||||
|
|
||||||
|
public function getName(): string;
|
||||||
|
|
||||||
|
public function availableQuantity(): ?int;
|
||||||
|
|
||||||
|
public function reserveStock(int $amount): void;
|
||||||
|
|
||||||
|
public function decrementReservedStock(int $amount): void;
|
||||||
|
|
||||||
|
public function buy(int $amount): void;
|
||||||
|
|
||||||
|
public function validateStock(): void;
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Shared\Contracts;
|
|
||||||
|
|
||||||
interface Stockable
|
|
||||||
{
|
|
||||||
public function incrementRealStock(int $amount): void;
|
|
||||||
public function decrementRealStock(int $amount): void;
|
|
||||||
|
|
||||||
public function incrementReservedStock(int $amount): void;
|
|
||||||
public function decrementReservedStock(int $amount): void;
|
|
||||||
public function confirmReservedStock(int $amount): void;
|
|
||||||
|
|
||||||
public function validateStock(): void;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user