$data */ public function create(array $data): CatalogItem { return DB::transaction(function () use ($data): CatalogItem { $type = CatalogItemType::from( $data['type'] ?? CatalogItemType::Standard->value, ); $variants = $data['variants'] ?? []; $images = $data['images'] ?? []; $attributeCodes = $data['attribute_codes'] ?? []; $components = $data['components'] ?? []; $hasDirectStock = array_key_exists('real_stock', $data); $realStock = (int) ($data['real_stock'] ?? 0); $hasVariants = $attributeCodes !== []; if ($type === CatalogItemType::Bundle) { $this->validateBundleData($data, $components); } else { if (array_key_exists('components', $data)) { throw ValidationException::withMessages([ 'components' => [__('api.catalog.standard_with_components')], ]); } $this->validateInventoryStrategy( $data, $variants, $hasVariants, $hasDirectStock, ); } unset( $data['variants'], $data['images'], $data['attribute_codes'], $data['components'], $data['real_stock'], $data['reserved_stock'], $data['sold_units'], $data['inventory_id'], ); $data['type'] = $type; if ($type === CatalogItemType::Bundle) { $data['inventory_id'] = null; $data['inventory_policy'] = null; $data['has_tickets'] = false; $data['minimum_use_date'] = null; $data['maximum_use_date'] = null; } elseif ($hasVariants) { $data['inventory_id'] = null; } else { $data['inventory_id'] = $this->createInventory($realStock)->id; } $catalogItem = CatalogItem::query()->create($data); $itemAttributes = $type === CatalogItemType::Standard ? $this->createItemAttributes($catalogItem, $attributeCodes) : []; if ($type === CatalogItemType::Bundle) { $this->createBundleComponents($catalogItem, $components); } $createdVariants = []; foreach ($variants as $index => $variantData) { $createdVariants[] = [ 'variant' => $this->createVariant( $catalogItem, $variantData, $itemAttributes, $index, ), 'images' => $variantData['images'] ?? [], 'index' => $index, ]; } $this->attachImages($catalogItem, $images, 'images'); foreach ($createdVariants as $createdVariant) { $this->attachImages( $createdVariant['variant'], $createdVariant['images'], "variants.{$createdVariant['index']}.images", ); } return $catalogItem->load([ 'attachments', 'inventory', 'category', 'brand', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]); }); } public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem { $catalogItem->load([ 'attachments', 'inventory', 'category', 'brand', 'itemAttributes.attribute.options', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.inventory', 'variants.attachments', 'variants.definitions' => fn ($query) => $query->orderBy('id'), 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem.inventory', 'bundleComponents.variant.inventory', 'bundleComponents.variant.definitions.itemAttribute.attribute', ]); $selectedVariant = $variantId === null ? $catalogItem->variants->first() : $catalogItem->variants->firstWhere('id', $variantId); if ($variantId !== null && $selectedVariant === null) { throw new NotFoundHttpException('Variant not found for catalog item.'); } $catalogItem->setRelation('selectedVariant', $selectedVariant); return $catalogItem; } /** @return LengthAwarePaginator */ public function search( Tenant $tenant, string $term, int $perPage, int $page, ): LengthAwarePaginator { $normalizedTerm = mb_strtolower($term); $containsPattern = "%{$normalizedTerm}%"; $startsWithPattern = "{$normalizedTerm}%"; $paginator = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where(function (Builder $query) use ($containsPattern): void { $query ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) ->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern]) ->orWhereHas( 'brand', fn (Builder $brandQuery) => $brandQuery ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) ) ->orWhereHas( 'category', fn (Builder $categoryQuery) => $categoryQuery ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) ); }) ->with([ 'attachments', 'inventory', 'variants.inventory', 'variants.attachments', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]) ->orderByRaw( 'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END', [$normalizedTerm, $startsWithPattern], ) ->orderBy('nombre') ->paginate(perPage: $perPage, pageName: 'page', page: $page); return $paginator->withPath(route('catalog-items.index', [ 'tenant' => $tenant->codigo, ])); } /** @return LengthAwarePaginator */ public function categoryItems( Tenant $tenant, Category $category, int $perPage, int $page, ): LengthAwarePaginator { return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) ->with([ 'attachments', 'inventory', 'variants.inventory', 'variants.attachments', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]) ->orderBy('nombre') ->paginate(perPage: $perPage, pageName: 'page', page: $page); } public function delete(CatalogItem $catalogItem): void { DB::transaction(function () use ($catalogItem): void { $catalogItem->load([ 'attachments', 'variants.attachments', ]); $attachments = $catalogItem->attachments ->merge($catalogItem->variants->flatMap->attachments) ->unique('id'); $inventoryIds = collect([$catalogItem->inventory_id]) ->merge($catalogItem->variants->pluck('inventory_id')) ->filter() ->unique(); $catalogItem->attachments()->detach(); foreach ($catalogItem->variants as $variant) { $variant->attachments()->detach(); } $catalogItem->delete(); Inventory::query()->whereKey($inventoryIds)->delete(); foreach ($attachments as $attachment) { if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { $this->attachmentService->delete($attachment); } } }); } private function createInventory(int $realStock): Inventory { return Inventory::query()->create([ 'real_stock' => $realStock, ]); } /** * @param array> $components */ private function createBundleComponents(CatalogItem $bundle, array $components): void { $seen = []; foreach (array_values($components) as $index => $componentData) { $catalogItemId = (int) $componentData['catalog_item_id']; $variantId = isset($componentData['variant_id']) ? (int) $componentData['variant_id'] : null; $key = $catalogItemId.':'.($variantId ?? 'direct'); if (isset($seen[$key])) { throw ValidationException::withMessages([ "components.{$index}" => [__('api.catalog.duplicate_component')], ]); } $seen[$key] = true; $componentItem = CatalogItem::query() ->whereKey($catalogItemId) ->where('tenant_code', $bundle->tenant_code) ->first(); if ($componentItem === null) { throw ValidationException::withMessages([ "components.{$index}.catalog_item_id" => [ __('api.catalog.component_wrong_tenant'), ], ]); } if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) { throw ValidationException::withMessages([ "components.{$index}.catalog_item_id" => [ __('api.catalog.invalid_component'), ], ]); } $hasVariants = $componentItem->variants()->exists(); if ($hasVariants && $variantId === null) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_required'), ], ]); } if (! $hasVariants && $variantId !== null) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_forbidden'), ], ]); } if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_invalid'), ], ]); } $bundle->bundleComponents()->create([ 'component_catalog_item_id' => $componentItem->id, 'component_variant_id' => $variantId, 'quantity' => (int) $componentData['quantity'], ]); } } /** * @param array $data * @param array> $components */ private function validateBundleData(array $data, array $components): void { if ($components === []) { throw ValidationException::withMessages([ 'components' => [__('api.catalog.bundle_component_required')], ]); } foreach ([ 'real_stock', 'inventory_policy', 'attribute_codes', 'variants', 'has_tickets', 'minimum_use_date', 'maximum_use_date', ] as $field) { if (array_key_exists($field, $data)) { throw ValidationException::withMessages([ $field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])], ]); } } } /** * @param array $images */ private function attachImages( CatalogItem|Variant $owner, array $images, string $validationKey, ): void { foreach (array_values($images) as $order => $image) { $attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}"); $owner->attachments()->attach($attachment->id, ['orden' => $order]); } } private function resolveAttachment(mixed $image, string $validationKey): Attachment { if (is_string($image) && Str::isUuid($image)) { $attachment = Attachment::query()->where('key', $image)->first(); if ($attachment === null) { throw ValidationException::withMessages([ $validationKey => [__('api.catalog.attachment_not_found')], ]); } return $attachment; } return $this->attachmentService->store($image, 'catalog-items'); } /** * @param array $attributeCodes * @return array */ private function createItemAttributes( CatalogItem $catalogItem, array $attributeCodes, ): array { $itemAttributes = []; $attributeCodes = array_values(array_unique($attributeCodes)); $attributes = Attribute::query() ->where('tenant_codigo', $catalogItem->tenant_code) ->whereIn('codigo', $attributeCodes) ->get() ->keyBy('codigo'); foreach ($attributeCodes as $attributeCode) { $attribute = $attributes->get($attributeCode); if ($attribute === null) { throw ValidationException::withMessages([ 'attribute_codes' => [ __('api.catalog.attribute_not_found', ['attribute' => $attributeCode]), ], ]); } $itemAttribute = $catalogItem->itemAttributes()->create([ 'attribute_id' => $attribute->id, ]); $itemAttributes[$attributeCode] = $itemAttribute; } return $itemAttributes; } /** * @param array $data * @param array $itemAttributes */ private function createVariant( CatalogItem $catalogItem, array $data, array $itemAttributes, int $index, ): Variant { unset($data['images']); if ( array_key_exists('inventory_id', $data) || array_key_exists('reserved_stock', $data) || array_key_exists('sold_units', $data) ) { throw ValidationException::withMessages([ "variants.{$index}.inventory" => [ __('api.catalog.managed_inventory_fields'), ], ]); } $inventory = $this->createInventory((int) ($data['real_stock'] ?? 0)); $variant = $catalogItem->variants()->create([ 'inventory_id' => $inventory->id, 'minimum_use_date' => $data['minimum_use_date'] ?? null, 'maximum_use_date' => $data['maximum_use_date'] ?? null, ]); $variant->setRelation('catalogItem', $catalogItem); $this->validateVariantUseDates($variant, $index); foreach ($data['values'] ?? [] as $attributeCode => $value) { $itemAttribute = $itemAttributes[$attributeCode] ?? null; if ($itemAttribute === null) { throw ValidationException::withMessages([ "variants.{$index}.values.{$attributeCode}" => [ __('api.catalog.attribute_not_on_item'), ], ]); } $variant->definitions()->create([ 'item_attribute_id' => $itemAttribute->id, 'value' => $value, ]); } return $variant; } private function validateVariantUseDates(Variant $variant, int $index): void { $minimumUseDate = $variant->getMinimumUseDate(); $maximumUseDate = $variant->getMaximumUseDate(); if ( $minimumUseDate !== null && $maximumUseDate !== null && $maximumUseDate->lessThan($minimumUseDate) ) { throw ValidationException::withMessages([ "variants.{$index}.maximum_use_date" => [ __('api.catalog.invalid_effective_date_range'), ], ]); } } /** * @param array $data * @param array> $variants */ private function validateInventoryStrategy( array $data, array $variants, bool $hasVariants, bool $hasDirectStock, ): void { if ($hasVariants && $variants === []) { throw ValidationException::withMessages([ 'variants' => [ __('api.catalog.variants_required'), ], ]); } if (! $hasVariants && $variants !== []) { throw ValidationException::withMessages([ 'variants' => [ __('api.catalog.variants_forbidden'), ], ]); } if ($hasVariants && $hasDirectStock) { throw ValidationException::withMessages([ 'real_stock' => [ __('api.catalog.direct_inventory_forbidden'), ], ]); } if ( array_key_exists('inventory_id', $data) || array_key_exists('reserved_stock', $data) || array_key_exists('sold_units', $data) ) { throw ValidationException::withMessages([ 'inventory' => [ __('api.catalog.managed_inventory_fields'), ], ]); } } }