where('tenant_code', $tenant->codigo) ->where('slug', 'comida') ->with([ 'variants.catalogItem', 'variants.inventory', 'variants.eventDate', 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', ]) ->first(); } /** * @param array> $variants */ public function upsertMany(Tenant $tenant, array $variants): CatalogItem { return DB::transaction(function () use ($tenant, $variants): CatalogItem { $attributes = $this->attributes($tenant); $food = $this->food($tenant, $variants); $itemAttributes = $this->itemAttributes($food, $attributes); $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $existingVariants = $food->variants() ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->lockForUpdate() ->get(); $resolvedVariants = $this->resolveVariants($variants, $attributes); $this->validateCombinations($resolvedVariants, $existingVariants); foreach ($resolvedVariants as $index => $data) { $variant = isset($data['id']) ? $existingVariants->firstWhere('id', (int) $data['id']) : null; if (isset($data['id']) && $variant === null) { throw ValidationException::withMessages([ "variants.{$index}.id" => ['La variante no pertenece al producto Comida.'], ]); } if ($variant === null) { $this->createVariant($food, $itemAttributes, $data); } else { $this->updateVariant($variant, $itemAttributes, $data, $index); } } $minimumPrice = $food->variants()->min('precio'); if ($minimumPrice !== null) { $food->update(['precio' => $minimumPrice]); } return $food->fresh()->load([ 'variants.catalogItem', 'variants.inventory', 'variants.eventDate', 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', ]); }); } public function delete(Tenant $tenant, int $foodId): void { $variant = Variant::query() ->whereKey($foodId) ->whereHas('catalogItem', fn ($query) => $query ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida')) ->firstOrFail(); $this->catalogService->deleteVariant($variant); } /** @return Collection */ private function attributes(Tenant $tenant): Collection { $attributes = Attribute::query() ->where('tenant_codigo', $tenant->codigo) ->whereIn('codigo', self::ATTRIBUTE_CODES) ->with('options') ->get() ->keyBy('codigo'); $missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys()); if ($missingCodes->isNotEmpty()) { throw ValidationException::withMessages([ 'variants' => [ 'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.', ], ]); } return $attributes; } /** @param array> $variants */ private function food(Tenant $tenant, array $variants): CatalogItem { $category = Category::query()->firstOrCreate([ 'tenant_code' => $tenant->codigo, 'nombre' => 'Comidas', ]); $food = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida') ->lockForUpdate() ->first(); if ($food !== null) { $food->update([ 'category_id' => $category->id, 'event_product_type' => EventProductType::Product->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => false, ]); return $food; } return CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'slug' => 'comida', 'nombre' => 'Comida', 'descripcion' => 'Comida', 'category_id' => $category->id, 'precio' => collect($variants)->min('price') ?? 0, '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 $food, Collection $attributes): Collection { return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array { $itemAttribute = $food->itemAttributes()->firstOrCreate( ['attribute_id' => $attribute->id], ['allow_multi_select' => false], ); return [$code => $itemAttribute]; }); } /** * @param array> $variants * @param Collection $attributes * @return array> */ private function resolveVariants(array $variants, Collection $attributes): array { return collect($variants)->map(function (array $variant, int $index) use ($attributes): array { $schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule"); $service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service"); return [ ...$variant, 'event_date_id' => (int) $variant['event_date_id'], 'schedule' => $schedule->value, 'service' => $service->value, 'description' => (string) ($variant['description'] ?? ''), 'stock' => (int) $variant['stock'], ]; })->all(); } private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption { $option = $attribute->options->first( fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value)) ); if ($option === null) { throw ValidationException::withMessages([ $validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."], ]); } return $option; } /** * @param array> $incoming * @param Collection $existing */ private function validateCombinations(array $incoming, Collection $existing): void { $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); $seen = []; foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { $values = $variant->selectionValues(); $seen[$this->combinationKey( (int) $variant->selectedEventDates()->first()?->id, (string) $values->get('horario'), (string) $values->get('servicio'), )] = true; } foreach ($incoming as $index => $variant) { $key = $this->combinationKey( $variant['event_date_id'], $variant['schedule'], $variant['service'], ); if (isset($seen[$key])) { throw ValidationException::withMessages([ "variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'], ]); } $seen[$key] = true; } } /** @param Collection $itemAttributes */ private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void { $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); $variant = $food->variants()->create([ 'event_date_id' => $data['event_date_id'], 'inventory_id' => $inventory->id, 'descripcion' => $data['description'], 'precio' => $data['price'], ]); $variant->eventDates()->sync([$data['event_date_id']]); $this->syncDefinitions($variant, $itemAttributes, $data); } /** @param Collection $itemAttributes */ private function updateVariant( Variant $variant, Collection $itemAttributes, array $data, int $index, ): void { $inventory = Inventory::query() ->whereKey($variant->inventory_id) ->lockForUpdate() ->firstOrFail(); if ($data['stock'] < $inventory->reserved_stock) { throw ValidationException::withMessages([ "variants.{$index}.stock" => [ 'El stock no puede ser menor que la cantidad actualmente reservada.', ], ]); } $variant->update([ 'event_date_id' => $data['event_date_id'], 'descripcion' => $data['description'], 'precio' => $data['price'], ]); $variant->eventDates()->sync([$data['event_date_id']]); $inventory->update(['real_stock' => $data['stock']]); $this->syncDefinitions($variant, $itemAttributes, $data); } /** @param Collection $itemAttributes */ private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void { $definitionAttributes = $itemAttributes->toBase()->only(['horario', 'servicio']); $variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete(); $variant->definitions()->createMany([ [ 'item_attribute_id' => $definitionAttributes['horario']->id, 'value' => $data['schedule'], ], [ 'item_attribute_id' => $definitionAttributes['servicio']->id, 'value' => $data['service'], ], ]); } private function combinationKey(int $eventDateId, string $schedule, string $service): string { return implode('|', [ $eventDateId, mb_strtolower(trim($schedule)), mb_strtolower(trim($service)), ]); } }