diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 71016ca..147c449 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -109,10 +109,23 @@ class Variant extends Model public function selectionValues(): Collection { $values = $this->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null); + ->groupBy('item_attribute_id') + ->mapWithKeys(function (Collection $definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $attributeCode = $itemAttribute?->attribute?->codigo; + + if ($attributeCode === null) { + return []; + } + + $definitionValues = $definitions->pluck('value')->values(); + + return [ + $attributeCode => $itemAttribute->allow_multi_select + ? $definitionValues->all() + : $definitionValues->first(), + ]; + }); $eventDateIds = $this->selectedEventDates() ->pluck('id') diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 222292c..e5d9cfc 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -109,7 +109,8 @@ class StoreCatalogItemRequest extends FormRequest 'variants.*.reserved_stock' => ['prohibited'], 'variants.*.sold_units' => ['prohibited'], 'variants.*.values' => ['sometimes', 'array'], - 'variants.*.values.*' => ['nullable', 'string'], + 'variants.*.values.*' => ['nullable'], + 'variants.*.values.*.*' => ['required', 'string'], 'variants.*.images' => ['sometimes', 'array'], 'variants.*.images.*' => ['required', new ImageOrBase64Rule], 'components' => [ diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 8fcc7ea..6e7b137 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -569,10 +569,16 @@ class CatalogService ]); } - $variant->definitions()->create([ - 'item_attribute_id' => $itemAttribute->id, - 'value' => $value, - ]); + foreach ($this->validatedVariantValues( + $itemAttribute, + $value, + "variants.{$index}.values.{$attributeCode}", + ) as $validatedValue) { + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => $validatedValue, + ]); + } } return $variant; @@ -618,8 +624,14 @@ class CatalogService $combination = [$eventDateIds]; foreach ($attributeCodes as $attributeCode) { - $value = trim((string) ($variant['values'][$attributeCode] ?? '')); - $combination[] = Str::ascii(mb_strtolower($value)); + $values = $variant['values'][$attributeCode] ?? ''; + $normalizedValues = collect(is_array($values) ? $values : [$values]) + ->map(fn ($value): string => $this->normalizeVariantValue((string) $value)) + ->unique() + ->sort() + ->values() + ->implode(','); + $combination[] = $normalizedValues; } $key = implode('|', $combination); @@ -633,6 +645,72 @@ class CatalogService } } + /** @return array */ + private function validatedVariantValues( + ItemAttribute $itemAttribute, + mixed $value, + string $validationKey, + ): array { + $values = is_array($value) ? array_values($value) : [$value]; + + if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) { + throw ValidationException::withMessages([ + $validationKey => [ + $itemAttribute->allow_multi_select + ? 'At least one value must be selected.' + : 'Exactly one value must be selected.', + ], + ]); + } + + if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) { + throw ValidationException::withMessages([ + $validationKey => ['Every selected value must be a non-empty string.'], + ]); + } + + $normalizedValues = collect($values) + ->map(fn (string $item): string => $this->normalizeVariantValue($item)); + + if ($normalizedValues->unique()->count() !== count($values)) { + throw ValidationException::withMessages([ + $validationKey => ['Selected values must be distinct.'], + ]); + } + + $attribute = $itemAttribute->attribute; + if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) { + $optionsByNormalizedValue = $attribute->options + ->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value)); + + $resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue)); + if ($resolvedOptions->contains(null)) { + throw ValidationException::withMessages([ + $validationKey => ['One or more selected values are not valid attribute options.'], + ]); + } + + $validityTimeIds = $resolvedOptions + ->pluck('validity_time_id') + ->filter() + ->unique(); + if ($validityTimeIds->count() > 1) { + throw ValidationException::withMessages([ + $validationKey => ['Selected values cannot have different validity windows.'], + ]); + } + + return $resolvedOptions->pluck('value')->all(); + } + + return collect($values)->map(fn (string $item): string => trim($item))->all(); + } + + private function normalizeVariantValue(string $value): string + { + return Str::ascii(mb_strtolower(trim($value))); + } + /** * @param array $data * @param array> $variants diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index aca7d9d..06938ab 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -99,10 +99,18 @@ class PurchaseItemResource extends JsonResource } $attributes = $variant->definitions - ->map(fn ($definition): array => [ - 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), - 'value' => $definition->value, - ]) + ->groupBy('item_attribute_id') + ->map(function ($definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $values = $definitions->pluck('value')->values(); + + return [ + 'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''), + 'value' => $itemAttribute?->allow_multi_select + ? $values->all() + : $values->first(), + ]; + }) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) ->values(); diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index f0974cf..fbfbcff 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -54,10 +54,18 @@ class PurchaseItemSnapshotFactory private function snapshotAttributes(Variant $variant): array { $attributes = $variant->definitions - ->map(fn ($definition): array => [ - 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), - 'value' => $definition->value, - ]) + ->groupBy('item_attribute_id') + ->map(function ($definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $values = $definitions->pluck('value')->values(); + + return [ + 'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''), + 'value' => $itemAttribute?->allow_multi_select + ? $values->all() + : $values->first(), + ]; + }) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) ->values(); diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index 6e79f22..e671c98 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -172,6 +172,32 @@ class CatalogServiceTest extends TestCase ); } + public function test_it_creates_multiple_values_for_any_multi_select_attribute(): void + { + $color = $this->createAttribute('color', FieldType::Select); + $color->options()->createMany([ + ['value' => 'Green', 'label' => 'Green', 'sort_order' => 1], + ['value' => 'White', 'label' => 'White', 'sort_order' => 2], + ]); + + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'multi-color-shirt', + 'nombre' => 'Multi-color shirt', + 'precio' => 100, + 'attribute_codes' => ['color'], + 'multi_select_attribute_codes' => ['color'], + 'variants' => [[ + 'real_stock' => 5, + 'values' => ['color' => ['White', 'Green']], + ]], + ]); + + $variant = $item->variants->sole(); + $this->assertSame(['White', 'Green'], $variant->definitions->pluck('value')->all()); + $this->assertSame(['White', 'Green'], $variant->selectionValues()->get('color')); + } + public function test_it_rejects_duplicate_variant_combinations(): void { $sector = $this->createAttribute('sector');