diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 476ec33..98a6649 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -2,10 +2,12 @@ namespace App\Domains\Catalog\Controllers; +use App\Domains\Cart\Services\CartService; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Requests\CatalogItemDetailRequest; +use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest; use App\Domains\Catalog\Requests\CategoryPageRequest; use App\Domains\Catalog\Requests\FeaturedGroupPageRequest; use App\Domains\Catalog\Requests\SearchCatalogItemsRequest; @@ -14,8 +16,10 @@ use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource; use App\Domains\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Catalog\Resources\CatalogItemResource; use App\Domains\Catalog\Resources\CatalogSearchItemResource; +use App\Domains\Catalog\Resources\CatalogVariantOptionsResource; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\FeaturedGroupService; +use App\Domains\Catalog\Services\VariantSelectionService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -110,6 +114,36 @@ class CatalogController extends Controller ); } + public function variantOptions( + CatalogVariantOptionsRequest $request, + Tenant $tenant, + CatalogItem $catalogItem, + VariantSelectionService $variantSelectionService, + CartService $cartService, + ): CatalogVariantOptionsResource { + abort_unless($catalogItem->tenant_code === $tenant->codigo, 404); + + $includedVariantId = null; + $cartItemId = $request->validated('cart_item_id'); + + if ($cartItemId !== null) { + $cartItem = $cartService->show($tenant, $request) + ->items + ->firstWhere('id', (int) $cartItemId); + abort_unless($cartItem?->catalog_item_id === $catalogItem->id, 404); + $includedVariantId = $cartItem->variant_id; + } + + return CatalogVariantOptionsResource::make( + $variantSelectionService->options( + $catalogItem, + $request->validated('selected_values', []), + array_map('intval', $request->validated('excluded_variant_ids', [])), + $includedVariantId, + ) + ); + } + public function store( StoreCatalogItemRequest $request, Tenant $tenant, diff --git a/app/Domains/Catalog/Requests/CatalogVariantOptionsRequest.php b/app/Domains/Catalog/Requests/CatalogVariantOptionsRequest.php new file mode 100644 index 0000000..de22393 --- /dev/null +++ b/app/Domains/Catalog/Requests/CatalogVariantOptionsRequest.php @@ -0,0 +1,25 @@ +> */ + public function rules(): array + { + return [ + 'selected_values' => ['sometimes', 'array'], + 'selected_values.*' => ['nullable'], + 'excluded_variant_ids' => ['sometimes', 'array'], + 'excluded_variant_ids.*' => ['integer', 'min:1', 'distinct'], + 'cart_item_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Domains/Catalog/Resources/CatalogVariantOptionsResource.php b/app/Domains/Catalog/Resources/CatalogVariantOptionsResource.php new file mode 100644 index 0000000..c5ef39c --- /dev/null +++ b/app/Domains/Catalog/Resources/CatalogVariantOptionsResource.php @@ -0,0 +1,15 @@ + */ + public function toArray(Request $request): array + { + return $this->resource; + } +} diff --git a/app/Domains/Catalog/Services/VariantSelectionService.php b/app/Domains/Catalog/Services/VariantSelectionService.php new file mode 100644 index 0000000..88f2a5b --- /dev/null +++ b/app/Domains/Catalog/Services/VariantSelectionService.php @@ -0,0 +1,145 @@ + $selectedValues + * @param list $excludedVariantIds + * @return array + */ + public function options( + CatalogItem $catalogItem, + array $selectedValues, + array $excludedVariantIds = [], + ?int $includedVariantId = null, + ): array { + $catalogItem->load([ + 'itemAttributes.attribute', + 'variants' => fn ($query) => $query->orderBy('id'), + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + 'variants.definitions' => fn ($query) => $query->orderBy('id'), + 'variants.definitions.itemAttribute.attribute.options', + ]); + + $excluded = collect($excludedVariantIds)->map(fn ($id): int => (int) $id); + $variants = $catalogItem->visibleVariants($includedVariantId) + ->reject(fn (Variant $variant): bool => $variant->id !== $includedVariantId + && $excluded->contains($variant->id)) + ->values(); + $normalizedSelections = collect($selectedValues) + ->mapWithKeys(fn ($value, string $key): array => [$key => $this->normalizeValue($value)]) + ->filter(fn ($value): bool => $value !== null && $value !== '' && $value !== []) + ->all(); + $matchingVariants = $variants + ->filter(fn (Variant $variant): bool => $this->matches($variant, $normalizedSelections)) + ->values(); + $attributeKeys = $variants + ->flatMap(fn (Variant $variant): array => $variant + ->selectorOptions($catalogItem->itemAttributes) + ->keys() + ->all()) + ->unique() + ->values(); + $isComplete = $attributeKeys->isNotEmpty() + && $attributeKeys->every(fn (string $key): bool => array_key_exists($key, $normalizedSelections)); + $resolvedVariant = $isComplete && $matchingVariants->count() === 1 + ? $matchingVariants->first() + : null; + + return [ + 'variants' => $variants + ->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant)) + ->values(), + 'options' => $this->availableOptions($catalogItem, $matchingVariants), + 'selected_values' => $normalizedSelections, + 'resolved_variant_id' => $resolvedVariant?->id, + 'valid' => $matchingVariants->isNotEmpty(), + ]; + } + + /** @param array $selectedValues */ + private function matches(Variant $variant, array $selectedValues): bool + { + $variantValues = $variant->selectionValues(); + + foreach ($selectedValues as $key => $selectedValue) { + if (! $variantValues->has($key) + || $this->valueKey($variantValues->get($key)) !== $this->valueKey($selectedValue)) { + return false; + } + } + + return true; + } + + /** + * @param Collection $variants + * @return array> + */ + private function availableOptions(CatalogItem $catalogItem, Collection $variants): array + { + $options = []; + $seen = []; + + foreach ($variants as $variant) { + foreach ($variant->selectorOptions($catalogItem->itemAttributes) as $key => $option) { + $optionKey = $this->valueKey($option); + if (isset($seen[$key][$optionKey])) { + continue; + } + + $seen[$key][$optionKey] = true; + $options[$key][] = $option; + } + } + + return $options; + } + + /** @return array */ + private function variantData(CatalogItem $catalogItem, Variant $variant): array + { + return [ + 'id' => $variant->id, + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), + 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(), + 'values' => $variant->selectorOptions($catalogItem->itemAttributes), + ]; + } + + private function normalizeValue(mixed $value): mixed + { + if (is_array($value) && array_key_exists('value', $value)) { + return (string) $value['value']; + } + + if (is_array($value)) { + return array_map(fn ($item) => $this->normalizeValue($item), $value); + } + + return is_scalar($value) ? (string) $value : null; + } + + private function valueKey(mixed $value): string + { + $normalized = $this->normalizeValue($value); + + if (is_array($normalized)) { + sort($normalized); + } + + return json_encode($normalized, JSON_THROW_ON_ERROR); + } +} diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index 2257693..5d7fa84 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -12,6 +12,7 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void { Route::get('categories/{category}', [CatalogController::class, 'category']) ->name('categories.show'); Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']); + Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']); Route::post('catalog-items', [CatalogController::class, 'store']); });