refactor(catalog): Complete catalog refactor to simplify its data model and its querying.

source commits: refactor/catalog
This commit is contained in:
2026-07-21 09:23:19 -03:00
parent d3182fbd13
commit 29a2ca19a3
116 changed files with 5141 additions and 5217 deletions

View File

@@ -4,17 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGroupItemRequest extends FormRequest
class CatalogItemDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'order' => ['sometimes', 'integer'],
'variant_id' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -4,20 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ProductDetailRequest extends FormRequest
class FeaturedGroupPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'variant_id' => ['sometimes', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -1,68 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
'array',
],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCatalogItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
$type = $this->input('type', CatalogItemType::Standard->value);
$isBundle = $type === CatalogItemType::Bundle->value;
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'category_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('categorias', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'brand_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('catalog_items', 'slug')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
'sold_units' => ['prohibited'],
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [
Rule::requiredIf($isBundle),
Rule::prohibitedIf(! $isBundle),
'array',
'min:1',
],
'components.*.catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'components.*.variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id'),
],
'components.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
'nombre' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['required', 'string', 'max:255'],
'product_layout' => ['required', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreGroupItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'groupable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
'groupable_id' => [
'required',
'integer',
Rule::exists(match ($this->input('groupable_type')) {
'bundle' => 'bundles',
default => 'productos_variantes',
}, 'id'),
],
'order' => ['nullable', 'integer'],
];
}
public function mappedGroupableType(): string
{
return match ($this->input('groupable_type')) {
'variant' => ProductVariant::class,
'bundle' => Bundle::class,
default => throw new \InvalidArgumentException('Invalid groupable type'),
};
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
];
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
),
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
];
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Attribute|null $attribute */
$attribute = $this->route('attribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('attribute', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => ['sometimes', 'array'],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
];
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Category;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Category|null $category */
$category = $this->route('categoria');
return [
'categoria_id' => [
'nullable',
'integer',
'exists:categorias,id',
Rule::notIn([$category?->id]),
],
'nombre' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['sometimes', 'string', 'max:255'],
'product_layout' => ['sometimes', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@@ -1,55 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Product;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Product|null $product */
$product = $this->route('producto');
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('productos', 'slug')->ignore($product?->id),
],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['prohibited'],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
),
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
];
}
}