*/ public function all(Tenant $tenant): Collection { return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('event_product_type', EventProductType::Product->value) ->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising')) ->with([ 'itemAttributes.attribute.options', 'variants.catalogItem', 'variants.inventory', 'variants.definitions', ]) ->orderBy('id') ->get(); } /** * @param array> $items * @return Collection */ public function upsertMany(Tenant $tenant, array $items): Collection { return DB::transaction(function () use ($tenant, $items): Collection { $attributes = $this->attributes($tenant); $category = Category::query()->firstOrCreate([ 'tenant_code' => $tenant->codigo, 'nombre' => 'Merchandising', ]); $reservedSlugs = []; return collect($items)->map(function (array $data, int $index) use ( $tenant, $attributes, $category, &$reservedSlugs, ): CatalogItem { $item = isset($data['id']) ? $this->existingItem($tenant, $category, (int) $data['id'], $index) : $this->createItem($tenant, $category, $data, $reservedSlugs); if (! isset($data['id'])) { $reservedSlugs[] = $item->slug; } $item->update([ 'nombre' => trim($data['title']), 'descripcion' => $data['description'] ?? null, 'category_id' => $category->id, 'max_units_per_user' => (int) $data['max_units_per_user'], 'event_product_type' => EventProductType::Product->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => false, ]); $itemAttributes = $this->itemAttributes($item, $attributes); $existingVariants = $item->variants() ->with(['inventory', 'definitions']) ->lockForUpdate() ->get(); $variants = $this->resolveVariants($data['variants'], $attributes, $index); $this->validateCombinations($variants, $existingVariants, $itemAttributes, $index); foreach ($variants as $variantIndex => $variantData) { $variant = isset($variantData['id']) ? $existingVariants->firstWhere('id', (int) $variantData['id']) : null; if (isset($variantData['id']) && $variant === null) { throw ValidationException::withMessages([ "items.{$index}.variants.{$variantIndex}.id" => [ 'La variante no pertenece al artículo de merchandising.', ], ]); } if ($variant === null) { $this->createVariant($item, $itemAttributes, $variantData); } else { $this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex); } } $minimumPrice = $item->variants()->min('precio'); if ($minimumPrice !== null) { $item->update(['precio' => $minimumPrice]); } return $item->fresh()->load([ 'itemAttributes.attribute.options', 'variants.catalogItem', 'variants.inventory', 'variants.definitions', ]); })->values(); }); } /** @return Collection */ private function attributes(Tenant $tenant): Collection { $attributes = Attribute::query() ->where('tenant_codigo', $tenant->codigo) ->whereIn('codigo', self::ATTRIBUTE_CODES) ->with('options') ->lockForUpdate() ->get() ->keyBy('codigo'); $missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys()); if ($missingCodes->isNotEmpty()) { throw ValidationException::withMessages([ 'items' => [ 'Faltan atributos requeridos para merchandising: '.$missingCodes->implode(', ').'.', ], ]); } return $attributes; } private function existingItem( Tenant $tenant, Category $category, int $itemId, int $index, ): CatalogItem { $item = CatalogItem::query() ->whereKey($itemId) ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) ->where('event_product_type', EventProductType::Product->value) ->lockForUpdate() ->first(); if ($item === null) { throw ValidationException::withMessages([ "items.{$index}.id" => ['El artículo no pertenece al merchandising del tenant.'], ]); } return $item; } /** * @param array $data * @param array $reservedSlugs */ private function createItem( Tenant $tenant, Category $category, array $data, array $reservedSlugs, ): CatalogItem { return CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'slug' => $this->uniqueSlug($tenant, $data['title'], $reservedSlugs), 'nombre' => trim($data['title']), 'descripcion' => $data['description'] ?? null, 'category_id' => $category->id, 'precio' => collect($data['variants'])->min('price') ?? 0, 'max_units_per_user' => (int) $data['max_units_per_user'], 'event_product_type' => EventProductType::Product->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => false, 'inventory_id' => null, ]); } /** * @param Collection $attributes * @return Collection */ private function itemAttributes(CatalogItem $item, Collection $attributes): Collection { return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($item): array { $itemAttribute = $item->itemAttributes()->firstOrCreate( ['attribute_id' => $attribute->id], ['allow_multi_select' => false], ); if ($itemAttribute->allow_multi_select) { $itemAttribute->update(['allow_multi_select' => false]); } return [$code => $itemAttribute]; }); } /** * @param array> $variants * @param Collection $attributes * @return array> */ private function resolveVariants(array $variants, Collection $attributes, int $itemIndex): array { return collect($variants)->map(function (array $variant, int $variantIndex) use ( $attributes, $itemIndex, ): array { $color = $this->resolveColor($attributes['color'], $variant['color']); $size = $this->existingOption( $attributes['talle'], $variant['size'], "items.{$itemIndex}.variants.{$variantIndex}.size", ); return [ ...$variant, 'color' => $color->value, 'size' => $size->value, 'stock' => (int) $variant['stock'], ]; })->all(); } private function resolveColor(Attribute $attribute, string $color): AttributeOption { $option = $this->findOption($attribute, $color); if ($option !== null) { return $option; } $label = trim($color); $option = $attribute->options()->create([ 'value' => $this->valueCode($label), 'label' => $label, 'sort_order' => ((int) $attribute->options->max('sort_order')) + 1, ]); $attribute->options->push($option); return $option; } private function existingOption( Attribute $attribute, string $value, string $validationKey, ): AttributeOption { $option = $this->findOption($attribute, $value); if ($option === null) { throw ValidationException::withMessages([ $validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."], ]); } return $option; } private function findOption(Attribute $attribute, string $value): ?AttributeOption { $key = $this->optionKey($value); return $attribute->options->first( fn (AttributeOption $option): bool => $this->optionKey($option->value) === $key || $this->optionKey($option->label) === $key ); } /** * @param array> $incoming * @param Collection $existing * @param Collection $itemAttributes */ private function validateCombinations( array $incoming, Collection $existing, Collection $itemAttributes, int $itemIndex, ): void { $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); $seen = []; foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { $values = $variant->definitions->keyBy('item_attribute_id'); $color = $values->get($itemAttributes['color']->id)?->value; $size = $values->get($itemAttributes['talle']->id)?->value; if ($color !== null && $size !== null) { $seen[$this->combinationKey($color, $size)] = true; } } foreach ($incoming as $variantIndex => $variant) { $key = $this->combinationKey($variant['color'], $variant['size']); if (isset($seen[$key])) { throw ValidationException::withMessages([ "items.{$itemIndex}.variants.{$variantIndex}" => [ 'La combinación de color y talle ya existe para el artículo.', ], ]); } $seen[$key] = true; } } /** * @param Collection $itemAttributes * @param array $data */ private function createVariant( CatalogItem $item, Collection $itemAttributes, array $data, ): void { $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); $variant = $item->variants()->create([ 'inventory_id' => $inventory->id, 'precio' => $data['price'], ]); $this->syncDefinitions($variant, $itemAttributes, $data); } /** * @param Collection $itemAttributes * @param array $data */ private function updateVariant( Variant $variant, Collection $itemAttributes, array $data, int $itemIndex, int $variantIndex, ): void { $inventory = Inventory::query() ->whereKey($variant->inventory_id) ->lockForUpdate() ->firstOrFail(); if ($data['stock'] < $inventory->reserved_stock) { throw ValidationException::withMessages([ "items.{$itemIndex}.variants.{$variantIndex}.stock" => [ 'El stock no puede ser menor que la cantidad actualmente reservada.', ], ]); } $variant->update(['precio' => $data['price']]); $inventory->update(['real_stock' => $data['stock']]); $this->syncDefinitions($variant, $itemAttributes, $data); } /** * @param Collection $itemAttributes * @param array $data */ private function syncDefinitions( Variant $variant, Collection $itemAttributes, array $data, ): void { $variant->definitions() ->whereIn('item_attribute_id', $itemAttributes->pluck('id')) ->delete(); $variant->definitions()->createMany([ [ 'item_attribute_id' => $itemAttributes['color']->id, 'value' => $data['color'], ], [ 'item_attribute_id' => $itemAttributes['talle']->id, 'value' => $data['size'], ], ]); } /** @param array $reservedSlugs */ private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string { $baseSlug = Str::slug($title) ?: 'merchandising'; $slug = $baseSlug; $suffix = 2; while ( in_array($slug, $reservedSlugs, true) || CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', $slug) ->exists() ) { $slug = "{$baseSlug}-{$suffix}"; $suffix++; } return $slug; } private function combinationKey(string $color, string $size): string { return $this->optionKey($color).'|'.$this->optionKey($size); } private function optionKey(string $value): string { return Str::ascii(mb_strtolower((string) preg_replace('/[_\s]+/u', ' ', trim($value)))); } private function valueCode(string $value): string { return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($value))); } }