Compare commits
12 Commits
46685a7141
...
a79f58d520
| Author | SHA1 | Date | |
|---|---|---|---|
| a79f58d520 | |||
| a7dba4dc10 | |||
| 4aedf7ca86 | |||
| a150a9f694 | |||
| 6d0d600b4b | |||
| 67c2a7350e | |||
| a0a0cc7255 | |||
| d6a17cb449 | |||
| 83f42f3cf9 | |||
| bbfe632926 | |||
| e78e64ed2e | |||
| 35384b48ab |
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -19,19 +18,39 @@ class CartItemResource extends JsonResource
|
||||
$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})";
|
||||
}
|
||||
|
||||
$imageUrl = null;
|
||||
if ($variant && $variant->relationLoaded('attachments')) {
|
||||
$firstAttachment = $variant->attachments->first();
|
||||
if ($firstAttachment) {
|
||||
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($product?->precio),
|
||||
'subtotal' => $this->formatMoney(($product?->precio ?? 0) * $this->cantidad),
|
||||
'product_id' => $product?->id,
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
'nombre' => $productName,
|
||||
'imagen' => $imageUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ class CartService
|
||||
{
|
||||
public function show(Tenant $tenant, Request $request): Cart
|
||||
{
|
||||
$identity = $this->resolveIdentity($request);
|
||||
$resolvedIdentity = $this->resolveIdentity($request);
|
||||
|
||||
if ($identity === null) {
|
||||
if ($resolvedIdentity === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
@@ -95,6 +95,7 @@ class CartService
|
||||
return $cart->fresh()->load([
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.productAttribute.attribute',
|
||||
'items.variant.attachments',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Requests\ProductDetailRequest;
|
||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
||||
use App\Domains\Catalog\Resources\ProductResource;
|
||||
@@ -30,11 +31,11 @@ class ProductController extends Controller
|
||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$variantId = $request->query('variant_id');
|
||||
$variantId = is_numeric($variantId) ? (int) $variantId : null;
|
||||
$variantId = $variantId !== null ? (int) $variantId : null;
|
||||
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
|
||||
|
||||
return ProductResource::make($producto);
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
#[Fillable([
|
||||
'producto_id',
|
||||
'stock',
|
||||
'is_placeholder',
|
||||
])]
|
||||
class ProductVariant extends Model
|
||||
{
|
||||
@@ -25,6 +26,7 @@ class ProductVariant extends Model
|
||||
return [
|
||||
'producto_id' => 'integer',
|
||||
'stock' => 'integer',
|
||||
'is_placeholder' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
23
app/Domains/Catalog/Requests/ProductDetailRequest.php
Normal file
23
app/Domains/Catalog/Requests/ProductDetailRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProductDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'variant_id' => ['sometimes', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class StoreProductRequest extends FormRequest
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'attribute_ids' => ['sometimes', 'array'],
|
||||
'attribute_ids.*' => [
|
||||
'required',
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductService
|
||||
@@ -26,7 +27,8 @@ class ProductService
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
$stock = $data['stock'] ?? 0;
|
||||
unset($data['attribute_ids'], $data['images'], $data['stock']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = Product::query()->create([
|
||||
@@ -40,6 +42,13 @@ class ProductService
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
// Create default variant with stock
|
||||
$this->createVariant($product, [
|
||||
'stock' => $stock,
|
||||
'is_placeholder' => true,
|
||||
'definitions' => [],
|
||||
]);
|
||||
|
||||
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
|
||||
});
|
||||
}
|
||||
@@ -109,6 +118,18 @@ class ProductService
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
// Determine if the variant being created is a placeholder one
|
||||
$hasDefinitions = ! empty($data['definitions']);
|
||||
$isPlaceholder = $data['is_placeholder'] ?? (! $hasDefinitions);
|
||||
$data['is_placeholder'] = $isPlaceholder;
|
||||
|
||||
// Remove any existing placeholder variants
|
||||
$defaultVariants = $product->variants()->where('is_placeholder', true)->get();
|
||||
foreach ($defaultVariants as $defaultVariant) {
|
||||
$this->deleteVariantAttachments($defaultVariant);
|
||||
$product->deleteVariant($defaultVariant);
|
||||
}
|
||||
|
||||
$variant = $product->createVariant($data);
|
||||
|
||||
if (! empty($images)) {
|
||||
@@ -153,6 +174,15 @@ class ProductService
|
||||
$product = $variant->product;
|
||||
$this->deleteVariantAttachments($variant);
|
||||
$product->deleteVariant($variant);
|
||||
|
||||
// Re-create a default variant with stock 0 if it has no variants left
|
||||
if ($product->variants()->count() === 0) {
|
||||
$product->createVariant([
|
||||
'stock' => 0,
|
||||
'is_placeholder' => true,
|
||||
'definitions' => [],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,6 +323,8 @@ class ProductService
|
||||
'variants.definitions.productAttribute.attribute.options',
|
||||
]);
|
||||
|
||||
$this->filterProductDetailAttributeOptions($product);
|
||||
|
||||
$selectedVariant = $variantId !== null
|
||||
? $product->variants->firstWhere('id', $variantId)
|
||||
: $product->variants->first(fn (ProductVariant $variant) => $variant->stock > 0);
|
||||
@@ -301,6 +333,12 @@ class ProductService
|
||||
throw new NotFoundHttpException('Product variant not found for product.');
|
||||
}
|
||||
|
||||
if ($variantId !== null && $selectedVariant->stock <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'La variante seleccionada no tiene stock.',
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedVariant ??= $product->variants->first();
|
||||
|
||||
if ($selectedVariant !== null) {
|
||||
@@ -313,4 +351,36 @@ class ProductService
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
protected function filterProductDetailAttributeOptions(Product $product): void
|
||||
{
|
||||
$availableValuesByAttributeId = [];
|
||||
|
||||
foreach ($product->variants as $variant) {
|
||||
foreach ($variant->definitions as $definition) {
|
||||
$attributeId = $definition->productAttribute?->attribute_id;
|
||||
|
||||
if ($attributeId === null || $definition->value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableValuesByAttributeId[$attributeId][$definition->value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($product->attributes as $attribute) {
|
||||
if (! $attribute->relationLoaded('options')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableValues = $availableValuesByAttributeId[$attribute->id] ?? [];
|
||||
|
||||
$attribute->setRelation(
|
||||
'options',
|
||||
$attribute->options
|
||||
->filter(fn ($option): bool => array_key_exists($option->value, $availableValues))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'danger_color',
|
||||
'success_color',
|
||||
'header_footer_bg_color',
|
||||
'header_logo_id',
|
||||
'footer_logo_id',
|
||||
|
||||
@@ -56,6 +56,7 @@ class StoreTenantRequest extends FormRequest
|
||||
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
|
||||
@@ -67,6 +67,7 @@ class UpdateTenantRequest extends FormRequest
|
||||
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
|
||||
@@ -23,6 +23,7 @@ class TenantResource extends JsonResource
|
||||
'primary_color' => $this->primary_color,
|
||||
'secondary_color' => $this->secondary_color,
|
||||
'danger_color' => $this->danger_color,
|
||||
'success_color' => $this->success_color,
|
||||
'header_footer_bg_color' => $this->header_footer_bg_color,
|
||||
// 1 day
|
||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('productos_variantes', function (Blueprint $table) {
|
||||
$table->boolean('is_placeholder')->default(false)->after('stock');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productos_variantes', function (Blueprint $table) {
|
||||
$table->dropColumn('is_placeholder');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('success_color')->default('#28a745')->after('danger_color');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn('success_color');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,52 @@ class AttributeSeeder extends Seeder
|
||||
$tenants = Tenant::all();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
// Seed Color attribute
|
||||
$existingColor = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('codigo', 'color')
|
||||
->first();
|
||||
|
||||
if ($existingColor) {
|
||||
Product::deleteAttribute($existingColor);
|
||||
}
|
||||
|
||||
Product::createAttribute($tenant, [
|
||||
'codigo' => 'color',
|
||||
'nombre' => 'Color',
|
||||
'type' => FieldType::Select->value,
|
||||
'is_required' => true,
|
||||
'metadata_schema' => [
|
||||
'hex' => ['type' => 'string'],
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'value' => 'Negro',
|
||||
'label' => 'Negro',
|
||||
'sort_order' => 1,
|
||||
'metadata' => ['hex' => '#000000'],
|
||||
],
|
||||
[
|
||||
'value' => 'Gris',
|
||||
'label' => 'Gris',
|
||||
'sort_order' => 2,
|
||||
'metadata' => ['hex' => '#808080'],
|
||||
],
|
||||
[
|
||||
'value' => 'Blanco',
|
||||
'label' => 'Blanco',
|
||||
'sort_order' => 3,
|
||||
'metadata' => ['hex' => '#FFFFFF'],
|
||||
],
|
||||
[
|
||||
'value' => 'Azul',
|
||||
'label' => 'Azul',
|
||||
'sort_order' => 4,
|
||||
'metadata' => ['hex' => '#0000FF'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Seed Talle (Size - Text options) attribute
|
||||
$existingTalle = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
@@ -64,51 +110,6 @@ class AttributeSeeder extends Seeder
|
||||
],
|
||||
]);
|
||||
|
||||
// Seed Color attribute
|
||||
$existingColor = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('codigo', 'color')
|
||||
->first();
|
||||
|
||||
if ($existingColor) {
|
||||
Product::deleteAttribute($existingColor);
|
||||
}
|
||||
|
||||
Product::createAttribute($tenant, [
|
||||
'codigo' => 'color',
|
||||
'nombre' => 'Color',
|
||||
'type' => FieldType::Select->value,
|
||||
'is_required' => true,
|
||||
'metadata_schema' => [
|
||||
'hex' => ['type' => 'string'],
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'value' => 'Negro',
|
||||
'label' => 'Negro',
|
||||
'sort_order' => 1,
|
||||
'metadata' => ['hex' => '#000000'],
|
||||
],
|
||||
[
|
||||
'value' => 'Gris',
|
||||
'label' => 'Gris',
|
||||
'sort_order' => 2,
|
||||
'metadata' => ['hex' => '#808080'],
|
||||
],
|
||||
[
|
||||
'value' => 'Blanco',
|
||||
'label' => 'Blanco',
|
||||
'sort_order' => 3,
|
||||
'metadata' => ['hex' => '#FFFFFF'],
|
||||
],
|
||||
[
|
||||
'value' => 'Azul',
|
||||
'label' => 'Azul',
|
||||
'sort_order' => 4,
|
||||
'metadata' => ['hex' => '#0000FF'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ class CategorySeeder extends Seeder
|
||||
'Remeras',
|
||||
'Pantalones',
|
||||
'Zapatillas',
|
||||
'Buzos'
|
||||
'Buzos',
|
||||
'Accesorios'
|
||||
];
|
||||
|
||||
foreach ($categories as $nombre) {
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
'variant_groups' => [
|
||||
'pantalon_blanco' => ['stock' => 14],
|
||||
'pantalon_negro' => ['stock' => 14],
|
||||
'pantalon_azul' => ['stock' => 0],
|
||||
],
|
||||
],
|
||||
'remera' => [
|
||||
@@ -38,10 +39,12 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
'variant_groups' => [
|
||||
'remera_blanca' => ['stock' => 22],
|
||||
'remera_negra' => ['stock' => 22],
|
||||
'remera_azul' => ['stock' => 0],
|
||||
],
|
||||
],
|
||||
'zapatillas_lecoq' => ['price' => 109999, 'stock' => 6],
|
||||
'zapatillas_topper' => ['price' => 104999, 'stock' => 7],
|
||||
'pelota_mundial' => ['price' => 45000, 'stock' => 15],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -220,6 +223,15 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
$variantGroup['files']
|
||||
);
|
||||
|
||||
if ($attributeIds->isEmpty()) {
|
||||
$this->productService->createVariant($product, [
|
||||
'stock' => $variantGroup['stock'],
|
||||
'definitions' => [],
|
||||
'images' => $images,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$definitions = [];
|
||||
|
||||
@@ -251,8 +263,23 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
'value' => $size,
|
||||
];
|
||||
|
||||
// Override stock for specific variants as requested by the user
|
||||
$stock = $variantGroup['stock'];
|
||||
$productType = $metadata['type'];
|
||||
$color = $variantGroup['metadata']['color'];
|
||||
|
||||
if ($productType === 'remera' && $color === 'Blanco' && $size === 'L') {
|
||||
$stock = 0;
|
||||
} elseif ($productType === 'remera' && $color === 'Negro' && $size === 'S') {
|
||||
$stock = 0;
|
||||
} elseif ($productType === 'pantalon' && $color === 'Negro' && $size === 'XL') {
|
||||
$stock = 0;
|
||||
} elseif ($productType === 'pantalon' && $color === 'Blanco' && $size === 'M') {
|
||||
$stock = 0;
|
||||
}
|
||||
|
||||
$this->productService->createVariant($product, [
|
||||
'stock' => $variantGroup['stock'],
|
||||
'stock' => $stock,
|
||||
'definitions' => $definitions,
|
||||
'images' => $images,
|
||||
]);
|
||||
@@ -301,6 +328,19 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
];
|
||||
}
|
||||
|
||||
if ($groupKey === 'pelota_mundial') {
|
||||
return [
|
||||
'type' => 'pelota',
|
||||
'category_name' => 'Accesorios',
|
||||
'brand_name' => 'Adidas',
|
||||
'color' => null,
|
||||
'attribute_codes' => [],
|
||||
'product_slug' => $groupKey,
|
||||
'product_name' => 'Pelota Mundial 2026',
|
||||
'description' => 'Pelota oficial del mundial 2026 sin variantes.',
|
||||
];
|
||||
}
|
||||
|
||||
$segments = explode('_', $groupKey);
|
||||
$type = array_shift($segments);
|
||||
|
||||
@@ -311,7 +351,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
$color = null;
|
||||
$brandSegments = $segments;
|
||||
|
||||
if (isset($segments[0]) && in_array($segments[0], ['blanco', 'blanca', 'negro', 'negra', 'gris'], true)) {
|
||||
if (isset($segments[0]) && in_array($segments[0], ['blanco', 'blanca', 'negro', 'negra', 'gris', 'azul'], true)) {
|
||||
$color = $this->normalizeColor($segments[0]);
|
||||
$brandSegments = array_slice($segments, 1);
|
||||
}
|
||||
@@ -361,6 +401,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
'blanco', 'blanca' => 'Blanco',
|
||||
'negro', 'negra' => 'Negro',
|
||||
'gris' => 'Gris',
|
||||
'azul' => 'Azul',
|
||||
default => throw new RuntimeException("Unsupported color '{$color}'."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class TenantSeeder extends Seeder
|
||||
'primary_color' => '#6376F3',
|
||||
'secondary_color' => '#A0A0A0',
|
||||
'danger_color' => '#FF8888',
|
||||
'success_color' => '#198754',
|
||||
'header_footer_bg_color' => '#313131',
|
||||
'header_logo' => $headerLogo,
|
||||
'footer_logo' => $footerLogo,
|
||||
|
||||
BIN
storage/public/catalog/pantalon_azul.png
Normal file
BIN
storage/public/catalog/pantalon_azul.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
storage/public/catalog/pelota_mundial.webp
Normal file
BIN
storage/public/catalog/pelota_mundial.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
storage/public/catalog/pelota_mundial_1.jpg
Normal file
BIN
storage/public/catalog/pelota_mundial_1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
storage/public/catalog/remera_azul.webp
Normal file
BIN
storage/public/catalog/remera_azul.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -5,6 +5,7 @@ namespace Tests\Feature\Cart;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Models\ProductVariantDefinition;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -22,11 +23,13 @@ class CartControllerTest extends TestCase
|
||||
$this->getJson('/api/tenants/acme/cart')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'id' => null,
|
||||
'tenant_codigo' => 'acme',
|
||||
'status' => 'active',
|
||||
'items' => [],
|
||||
'subtotal' => '0.00',
|
||||
'data' => [
|
||||
'id' => null,
|
||||
'tenant_codigo' => 'acme',
|
||||
'status' => 'active',
|
||||
'items' => [],
|
||||
'subtotal' => '0.00',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -40,9 +43,14 @@ class CartControllerTest extends TestCase
|
||||
'type' => 'string',
|
||||
]);
|
||||
|
||||
$productAttribute = ProductAttribute::query()->create([
|
||||
'product_id' => $variant->producto_id,
|
||||
'attribute_id' => $attribute->id,
|
||||
]);
|
||||
|
||||
ProductVariantDefinition::query()->create([
|
||||
'producto_variante_id' => $variant->id,
|
||||
'attribute_id' => $attribute->id,
|
||||
'products_attribute_id' => $productAttribute->id,
|
||||
'value' => 'Red',
|
||||
]);
|
||||
|
||||
@@ -54,14 +62,13 @@ class CartControllerTest extends TestCase
|
||||
$response
|
||||
->assertOk()
|
||||
->assertCookie('guest_token')
|
||||
->assertJsonPath('tenant_codigo', 'acme')
|
||||
->assertJsonPath('items.0.cantidad', 2)
|
||||
->assertJsonPath('items.0.precio_unitario', '49.90')
|
||||
->assertJsonPath('items.0.subtotal', '99.80')
|
||||
->assertJsonPath('items.0.product.id', $variant->product->id)
|
||||
->assertJsonPath('items.0.variant.id', $variant->id)
|
||||
->assertJsonPath('items.0.variant.definitions.0.attribute.codigo', 'color')
|
||||
->assertJsonPath('subtotal', '99.80');
|
||||
->assertJsonPath('data.tenant_codigo', 'acme')
|
||||
->assertJsonPath('data.items.0.cantidad', 2)
|
||||
->assertJsonPath('data.items.0.precio_unitario', '49.90')
|
||||
->assertJsonPath('data.items.0.product_id', $variant->product->id)
|
||||
->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)')
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.subtotal', '99.80');
|
||||
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'tenant_codigo' => 'acme',
|
||||
@@ -97,9 +104,8 @@ class CartControllerTest extends TestCase
|
||||
'cantidad' => 3,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('items.0.cantidad', 5)
|
||||
->assertJsonPath('items.0.subtotal', '125.00')
|
||||
->assertJsonPath('subtotal', '125.00');
|
||||
->assertJsonPath('data.items.0.cantidad', 5)
|
||||
->assertJsonPath('data.subtotal', '125.00');
|
||||
|
||||
$this->assertDatabaseCount('carritos', 1);
|
||||
$this->assertDatabaseCount('carrito_items', 1);
|
||||
@@ -129,9 +135,8 @@ class CartControllerTest extends TestCase
|
||||
'cantidad' => 5,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('items.0.cantidad', 5)
|
||||
->assertJsonPath('items.0.subtotal', '75.00')
|
||||
->assertJsonPath('subtotal', '75.00');
|
||||
->assertJsonPath('data.items.0.cantidad', 5)
|
||||
->assertJsonPath('data.subtotal', '75.00');
|
||||
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'producto_variante_id' => $variant->id,
|
||||
@@ -157,8 +162,8 @@ class CartControllerTest extends TestCase
|
||||
$this->withCookie('guest_token', $guestToken)
|
||||
->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('items', [])
|
||||
->assertJsonPath('subtotal', '0.00');
|
||||
->assertJsonPath('data.items', [])
|
||||
->assertJsonPath('data.subtotal', '0.00');
|
||||
|
||||
$this->assertDatabaseCount('carrito_items', 0);
|
||||
$this->assertDatabaseHas('productos_variantes', [
|
||||
@@ -187,7 +192,7 @@ class CartControllerTest extends TestCase
|
||||
'cantidad' => 2,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('subtotal', '50.00');
|
||||
->assertJsonPath('data.subtotal', '50.00');
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/tenants/globex/cart/items', [
|
||||
@@ -324,6 +329,7 @@ class CartControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#28a745',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
|
||||
@@ -122,6 +122,7 @@ class AttributeControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#28a745',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog;
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
@@ -19,6 +20,8 @@ class ProductControllerTest extends TestCase
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
private Brand $brand;
|
||||
|
||||
private Attribute $sizeAttr;
|
||||
|
||||
private Attribute $colorAttr;
|
||||
@@ -31,6 +34,11 @@ class ProductControllerTest extends TestCase
|
||||
|
||||
$this->tenant = $this->createTenant('acme', 'Acme Inc.', 'acme.com');
|
||||
|
||||
$this->brand = Brand::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'nombre' => 'Adidas',
|
||||
]);
|
||||
|
||||
// Create attributes for variants
|
||||
$this->sizeAttr = Attribute::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
@@ -69,6 +77,7 @@ class ProductControllerTest extends TestCase
|
||||
{
|
||||
$payload = [
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'remera-sport',
|
||||
'nombre' => 'Remera Sport',
|
||||
'descripcion' => 'Remera para hacer deportes',
|
||||
@@ -109,44 +118,43 @@ class ProductControllerTest extends TestCase
|
||||
'attribute_id' => $this->colorAttr->id,
|
||||
]);
|
||||
|
||||
$productAttributes = [
|
||||
'size' => $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail(),
|
||||
'color' => $product->productAttributes()->where('attribute_id', $this->colorAttr->id)->firstOrFail(),
|
||||
];
|
||||
|
||||
// Create a variant
|
||||
$variantPayload = [
|
||||
'producto_id' => $product->id,
|
||||
'slug' => 'remera-sport-s-azul',
|
||||
'nombre' => 'Remera Sport S Azul',
|
||||
'stock' => 10,
|
||||
'precio' => 15000.00,
|
||||
'definitions' => [
|
||||
[
|
||||
'attribute_id' => $this->sizeAttr->id,
|
||||
'products_attribute_id' => $productAttributes['size']->id,
|
||||
'value' => 'S',
|
||||
],
|
||||
[
|
||||
'attribute_id' => $this->colorAttr->id,
|
||||
'products_attribute_id' => $productAttributes['color']->id,
|
||||
'value' => 'Azul',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
|
||||
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
|
||||
$variantResponse->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('productos_variantes', [
|
||||
'producto_id' => $product->id,
|
||||
'slug' => 'remera-sport-s-azul',
|
||||
'stock' => 10,
|
||||
'precio' => 15000.00,
|
||||
]);
|
||||
|
||||
$variantS = ProductVariant::where('slug', 'remera-sport-s-azul')->firstOrFail();
|
||||
$variantS = ProductVariant::where('producto_id', $product->id)->where('stock', 10)->firstOrFail();
|
||||
$this->assertDatabaseHas('productos_variantes_values', [
|
||||
'producto_variante_id' => $variantS->id,
|
||||
'attribute_id' => $this->sizeAttr->id,
|
||||
'products_attribute_id' => $productAttributes['size']->id,
|
||||
'value' => 'S',
|
||||
]);
|
||||
$this->assertDatabaseHas('productos_variantes_values', [
|
||||
'producto_variante_id' => $variantS->id,
|
||||
'attribute_id' => $this->colorAttr->id,
|
||||
'products_attribute_id' => $productAttributes['color']->id,
|
||||
'value' => 'Azul',
|
||||
]);
|
||||
}
|
||||
@@ -157,6 +165,7 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pantalon-cargo',
|
||||
'nombre' => 'Pantalon Cargo',
|
||||
'precio' => 20000.00,
|
||||
@@ -167,6 +176,7 @@ class ProductControllerTest extends TestCase
|
||||
// 2. Perform update payload - change name and update attribute_ids to sizeAttr
|
||||
$payload = [
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pantalon-cargo-new-slug',
|
||||
'nombre' => 'Pantalon Cargo V2',
|
||||
'precio' => 22000.00,
|
||||
@@ -202,35 +212,33 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pantalon-cargo',
|
||||
'nombre' => 'Pantalon Cargo',
|
||||
'precio' => 20000.00,
|
||||
]);
|
||||
$product->attributes()->sync([$this->sizeAttr->id]);
|
||||
$sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail();
|
||||
|
||||
// Attempt to create variant with colorAttr (which is not associated with the product)
|
||||
$variantPayload = [
|
||||
'producto_id' => $product->id,
|
||||
'slug' => 'pantalon-cargo-38-rojo',
|
||||
'nombre' => 'Pantalon Cargo 38 Rojo',
|
||||
'stock' => 5,
|
||||
'precio' => 20000.00,
|
||||
'definitions' => [
|
||||
[
|
||||
'attribute_id' => $this->sizeAttr->id,
|
||||
'products_attribute_id' => $sizeProductAttr->id,
|
||||
'value' => '38',
|
||||
],
|
||||
[
|
||||
'attribute_id' => $this->colorAttr->id, // not associated!
|
||||
'products_attribute_id' => 99999, // not associated!
|
||||
'value' => 'Rojo',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
|
||||
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
|
||||
}
|
||||
|
||||
public function test_it_rejects_variant_update_with_attributes_not_associated_with_product(): void
|
||||
@@ -239,46 +247,41 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pantalon-cargo',
|
||||
'nombre' => 'Pantalon Cargo',
|
||||
'precio' => 20000.00,
|
||||
]);
|
||||
$product->attributes()->sync([$this->sizeAttr->id]);
|
||||
$sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail();
|
||||
|
||||
$variant = $product->variants()->create([
|
||||
'slug' => 'pantalon-cargo-38',
|
||||
'nombre' => 'Pantalon Cargo 38',
|
||||
'stock' => 5,
|
||||
'precio' => 20000.00,
|
||||
]);
|
||||
$variant->definitions()->create([
|
||||
'attribute_id' => $this->sizeAttr->id,
|
||||
'products_attribute_id' => $sizeProductAttr->id,
|
||||
'value' => '38',
|
||||
]);
|
||||
|
||||
// Attempt to update variant with colorAttr (which is not associated with the product)
|
||||
$variantPayload = [
|
||||
'producto_id' => $product->id,
|
||||
'slug' => 'pantalon-cargo-38-rojo',
|
||||
'nombre' => 'Pantalon Cargo 38 Rojo',
|
||||
'stock' => 5,
|
||||
'precio' => 20000.00,
|
||||
'definitions' => [
|
||||
[
|
||||
'attribute_id' => $this->sizeAttr->id,
|
||||
'products_attribute_id' => $sizeProductAttr->id,
|
||||
'value' => '38',
|
||||
],
|
||||
[
|
||||
'attribute_id' => $this->colorAttr->id, // not associated!
|
||||
'products_attribute_id' => 99999, // not associated!
|
||||
'value' => 'Rojo',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/product-variants/{$variant->id}", $variantPayload);
|
||||
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", $variantPayload);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
|
||||
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
|
||||
}
|
||||
|
||||
public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void
|
||||
@@ -286,20 +289,19 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'short-running',
|
||||
'nombre' => 'Short Running',
|
||||
'precio' => 8000.00,
|
||||
]);
|
||||
|
||||
$v1 = $product->variants()->create([
|
||||
'slug' => 'short-running-m',
|
||||
'nombre' => 'Short Running M',
|
||||
'stock' => 5,
|
||||
'precio' => 8000.00,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'short-running',
|
||||
'nombre' => 'Short Running Updated',
|
||||
'precio' => 9000.00,
|
||||
@@ -319,7 +321,6 @@ class ProductControllerTest extends TestCase
|
||||
// Variant should still exist untouched
|
||||
$this->assertDatabaseHas('productos_variantes', [
|
||||
'id' => $v1->id,
|
||||
'slug' => 'short-running-m',
|
||||
'stock' => 5,
|
||||
]);
|
||||
}
|
||||
@@ -329,29 +330,26 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pantalon-cargo',
|
||||
'nombre' => 'Pantalon Cargo',
|
||||
'precio' => 20000.00,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'producto_id' => $product->id,
|
||||
'slug' => 'remera-sport-s-azul',
|
||||
'nombre' => 'Remera Sport S Azul',
|
||||
'stock' => 10,
|
||||
'precio' => 15000.00,
|
||||
'definitions' => [
|
||||
[
|
||||
'attribute_id' => 99999, // Non-existent ID
|
||||
'products_attribute_id' => 99999, // Non-existent ID
|
||||
'value' => 'S',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $payload);
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $payload);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['definitions.0.attribute_id']);
|
||||
$response->assertJsonValidationErrors(['definitions.0.products_attribute_id']);
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void
|
||||
@@ -371,24 +369,23 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-prod-validation',
|
||||
'nombre' => 'Test Prod Validation',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
$product->attributes()->sync([$selectAttr->id]);
|
||||
$productAttr = $product->productAttributes()->where('attribute_id', $selectAttr->id)->firstOrFail();
|
||||
|
||||
// 3. Expect exception when creating variant with invalid value 'G'
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("The value 'G' is not a valid option for the select attribute 'Tamanho'.");
|
||||
|
||||
$product->createVariant([
|
||||
'slug' => 'test-prod-validation-g',
|
||||
'nombre' => 'Test Prod Validation G',
|
||||
'stock' => 5,
|
||||
'precio' => 100.00,
|
||||
'definitions' => [
|
||||
[
|
||||
'attribute_id' => $selectAttr->id,
|
||||
'products_attribute_id' => $productAttr->id,
|
||||
'value' => 'G', // Invalid value
|
||||
],
|
||||
],
|
||||
@@ -400,11 +397,16 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-show',
|
||||
'nombre' => 'Test Product Show',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
$productAttributes = $this->syncVariantAttributes($product);
|
||||
$this->colorAttr->options()->create([
|
||||
'value' => 'Verde',
|
||||
'label' => 'Verde',
|
||||
]);
|
||||
|
||||
$variant = $product->createVariant([
|
||||
'stock' => 0,
|
||||
@@ -479,6 +481,23 @@ class ProductControllerTest extends TestCase
|
||||
$response->assertJsonPath('data.variant.definitions.color', 'Rojo');
|
||||
$response->assertJsonCount(1, 'data.variant.images');
|
||||
$this->assertStringContainsString($variantAttachment->path, $response->json('data.variant.images.0'));
|
||||
|
||||
$colorAttribute = collect($response->json('data.attributes'))->firstWhere('codigo', 'color');
|
||||
$this->assertNotNull($colorAttribute);
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['Azul', 'Rojo'],
|
||||
collect($colorAttribute['options'])->pluck('value')->all()
|
||||
);
|
||||
|
||||
$attributesResponse = $this->getJson("/api/tenants/{$this->tenant->codigo}/attributes");
|
||||
$attributesResponse->assertOk();
|
||||
|
||||
$masterColorAttribute = collect($attributesResponse->json('data'))->firstWhere('codigo', 'color');
|
||||
$this->assertNotNull($masterColorAttribute);
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['Azul', 'Rojo', 'Verde'],
|
||||
collect($masterColorAttribute['options'])->pluck('value')->all()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_returns_requested_variant_in_product_detail(): void
|
||||
@@ -486,6 +505,7 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-requested-variant',
|
||||
'nombre' => 'Test Product Requested Variant',
|
||||
'precio' => 100.00,
|
||||
@@ -524,6 +544,7 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-invalid-variant',
|
||||
'nombre' => 'Test Product Invalid Variant',
|
||||
'precio' => 100.00,
|
||||
@@ -531,6 +552,7 @@ class ProductControllerTest extends TestCase
|
||||
$otherProduct = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-other-variant',
|
||||
'nombre' => 'Test Product Other Variant',
|
||||
'precio' => 100.00,
|
||||
@@ -548,6 +570,7 @@ class ProductControllerTest extends TestCase
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-fallback-images',
|
||||
'nombre' => 'Test Product Fallback Images',
|
||||
'precio' => 100.00,
|
||||
@@ -591,6 +614,160 @@ class ProductControllerTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function test_it_creates_default_variant_on_product_creation(): void
|
||||
{
|
||||
$payload = [
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'pelota-mundial-test',
|
||||
'nombre' => 'Pelota Mundial Test',
|
||||
'precio' => 45000.00,
|
||||
'stock' => 15,
|
||||
];
|
||||
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload);
|
||||
$response->assertCreated();
|
||||
|
||||
$product = Product::where('slug', 'pelota-mundial-test')->firstOrFail();
|
||||
|
||||
// Should have exactly 1 variant
|
||||
$this->assertEquals(1, $product->variants()->count());
|
||||
|
||||
$variant = $product->variants()->first();
|
||||
$this->assertEquals(15, $variant->stock);
|
||||
$this->assertTrue($variant->is_placeholder);
|
||||
// Should have no definitions
|
||||
$this->assertEquals(0, $variant->definitions()->count());
|
||||
}
|
||||
|
||||
public function test_it_removes_default_variant_when_creating_real_variant(): void
|
||||
{
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-default-variant-lifecycle',
|
||||
'nombre' => 'Test Default Variant Lifecycle',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
$productAttributes = $this->syncVariantAttributes($product);
|
||||
|
||||
$defaultVariant = $product->variants()->create([
|
||||
'stock' => 10,
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, $product->variants()->count());
|
||||
|
||||
// Create a real variant (with definitions)
|
||||
$variantPayload = [
|
||||
'stock' => 5,
|
||||
'definitions' => [
|
||||
[
|
||||
'products_attribute_id' => $productAttributes['size']->id,
|
||||
'value' => 'S',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
|
||||
$response->assertCreated();
|
||||
|
||||
// The default variant should be deleted
|
||||
$this->assertDatabaseMissing('productos_variantes', [
|
||||
'id' => $defaultVariant->id,
|
||||
]);
|
||||
|
||||
// Only the new variant should remain
|
||||
$this->assertEquals(1, $product->variants()->count());
|
||||
$newVariant = $product->variants()->first();
|
||||
$this->assertEquals(5, $newVariant->stock);
|
||||
$this->assertFalse($newVariant->is_placeholder);
|
||||
}
|
||||
|
||||
public function test_it_restores_default_variant_when_all_variants_are_deleted(): void
|
||||
{
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-default-variant-restore',
|
||||
'nombre' => 'Test Default Variant Restore',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
$productAttributes = $this->syncVariantAttributes($product);
|
||||
|
||||
$realVariant = $product->createVariant([
|
||||
'stock' => 5,
|
||||
'is_placeholder' => false,
|
||||
'definitions' => [
|
||||
[
|
||||
'products_attribute_id' => $productAttributes['size']->id,
|
||||
'value' => 'S',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, $product->variants()->count());
|
||||
|
||||
// Delete the real variant via API
|
||||
$response = $this->deleteJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$realVariant->id}");
|
||||
$response->assertNoContent();
|
||||
|
||||
// A default variant should be recreated with stock 0 and is_placeholder = true
|
||||
$this->assertEquals(1, $product->variants()->count());
|
||||
$defaultVariant = $product->variants()->first();
|
||||
$this->assertEquals(0, $defaultVariant->stock);
|
||||
$this->assertTrue($defaultVariant->is_placeholder);
|
||||
$this->assertEquals(0, $defaultVariant->definitions()->count());
|
||||
}
|
||||
|
||||
public function test_it_rejects_product_detail_when_requested_variant_has_no_stock(): void
|
||||
{
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-no-stock',
|
||||
'nombre' => 'Test Product No Stock',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
$productAttributes = $this->syncVariantAttributes($product);
|
||||
|
||||
$outOfStockVariant = $product->createVariant([
|
||||
'stock' => 0,
|
||||
'definitions' => [
|
||||
[
|
||||
'products_attribute_id' => $productAttributes['size']->id,
|
||||
'value' => 'S',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$outOfStockVariant->id}");
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['variant_id']);
|
||||
$response->assertJsonPath('errors.variant_id.0', 'La variante seleccionada no tiene stock.');
|
||||
}
|
||||
|
||||
public function test_it_rejects_product_detail_when_requested_variant_id_is_invalid_format(): void
|
||||
{
|
||||
$product = Product::create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'categoria_id' => 1,
|
||||
'brand_id' => $this->brand->id,
|
||||
'slug' => 'test-product-invalid-format',
|
||||
'nombre' => 'Test Product Invalid Format',
|
||||
'precio' => 100.00,
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id=abc");
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['variant_id']);
|
||||
}
|
||||
|
||||
private function createAttachment(string $path): Attachment
|
||||
{
|
||||
return Attachment::create([
|
||||
@@ -629,6 +806,7 @@ class ProductControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#28a745',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
|
||||
@@ -41,6 +41,7 @@ class ProductVariantAttachmentTest extends TestCase
|
||||
'primary_color' => '#ffffff',
|
||||
'secondary_color' => '#ffffff',
|
||||
'danger_color' => '#ffffff',
|
||||
'success_color' => '#ffffff',
|
||||
'header_footer_bg_color' => '#ffffff',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
|
||||
@@ -40,6 +40,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#ff0000',
|
||||
'secondary_color' => '#00ff00',
|
||||
'danger_color' => '#0000ff',
|
||||
'success_color' => '#00ff00',
|
||||
'header_footer_bg_color' => '#ffffff',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
@@ -54,6 +55,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.primary_color', '#ff0000')
|
||||
->assertJsonPath('data.secondary_color', '#00ff00')
|
||||
->assertJsonPath('data.danger_color', '#0000ff')
|
||||
->assertJsonPath('data.success_color', '#00ff00')
|
||||
->assertJsonPath('data.header_footer_bg_color', '#ffffff');
|
||||
|
||||
$headerUrl = $response->json('data.header_logo');
|
||||
@@ -107,6 +109,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => $base64Image,
|
||||
'footer_logo' => $base64Image,
|
||||
@@ -118,6 +121,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.primary_color', '#111111')
|
||||
->assertJsonPath('data.secondary_color', '#222222')
|
||||
->assertJsonPath('data.danger_color', '#333333')
|
||||
->assertJsonPath('data.success_color', '#555555')
|
||||
->assertJsonPath('data.header_footer_bg_color', '#444444');
|
||||
|
||||
$tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail();
|
||||
@@ -130,6 +134,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $tenant->header_logo_id,
|
||||
'footer_logo_id' => $tenant->footer_logo_id,
|
||||
@@ -154,6 +159,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
@@ -203,6 +209,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#555555',
|
||||
'secondary_color' => '#666666',
|
||||
'danger_color' => '#777777',
|
||||
'success_color' => '#999999',
|
||||
'header_footer_bg_color' => '#888888',
|
||||
'header_logo' => $hdrUuid,
|
||||
'footer_logo' => $ftrUuid,
|
||||
@@ -215,6 +222,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.primary_color', '#555555')
|
||||
->assertJsonPath('data.secondary_color', '#666666')
|
||||
->assertJsonPath('data.danger_color', '#777777')
|
||||
->assertJsonPath('data.success_color', '#999999')
|
||||
->assertJsonPath('data.header_footer_bg_color', '#888888');
|
||||
|
||||
$headerUrl = $successfulResponse->json('data.header_logo');
|
||||
@@ -234,6 +242,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#555555',
|
||||
'secondary_color' => '#666666',
|
||||
'danger_color' => '#777777',
|
||||
'success_color' => '#999999',
|
||||
'header_footer_bg_color' => '#888888',
|
||||
'header_logo_id' => $hdrAttachment->id,
|
||||
'footer_logo_id' => $ftrAttachment->id,
|
||||
@@ -286,6 +295,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => 'invalid-color',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
@@ -300,12 +310,28 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#12345',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response2->assertJsonValidationErrors(['primary_color']);
|
||||
|
||||
$response3 = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => 'invalid-color',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response3->assertJsonValidationErrors(['success_color']);
|
||||
}
|
||||
|
||||
public function test_it_validates_logo_must_be_image_or_svg(): void
|
||||
@@ -319,6 +345,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
@@ -333,6 +360,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => 'data:application/pdf;base64,JVBERi0xLjQKJdcfqksKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAvUGFnZXMgMiAwIFI...',
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
@@ -355,6 +383,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => $header,
|
||||
'footer_logo' => $footer,
|
||||
@@ -410,6 +439,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#ff0000',
|
||||
'secondary_color' => '#00ff00',
|
||||
'danger_color' => '#0000ff',
|
||||
'success_color' => '#00ff00',
|
||||
'header_footer_bg_color' => '#ffffff',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
@@ -438,6 +468,7 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_footer_bg_color' => '#444444',
|
||||
'header_logo' => $base64Image,
|
||||
'footer_logo' => $ftrUuid,
|
||||
|
||||
Reference in New Issue
Block a user