feat(catalog): resolve options from partial variant selections
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CatalogVariantOptionsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CatalogVariantOptionsResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->resource;
|
||||
}
|
||||
}
|
||||
145
app/Domains/Catalog/Services/VariantSelectionService.php
Normal file
145
app/Domains/Catalog/Services/VariantSelectionService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class VariantSelectionService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $selectedValues
|
||||
* @param list<int> $excludedVariantIds
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<int, Variant> $variants
|
||||
* @return array<string, list<mixed>>
|
||||
*/
|
||||
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<string, mixed> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user