diff --git a/config/services.xml b/config/services.xml index c007c44a..c50f636a 100644 --- a/config/services.xml +++ b/config/services.xml @@ -132,6 +132,11 @@ + + + + + diff --git a/phpstan.neon b/phpstan.neon index c8aab8e1..e9be45f6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,3 +22,5 @@ parameters: message: '/Cannot call method trans\(\) on Symfony\\Contracts\\Translation\\TranslatorInterface\|null\./' path: src/Controller/ProductEnqueueController + # Move alias here when global alias are supported also on Psalm: https://github.com/vimeo/psalm/discussions/5376 + typeAliases: diff --git a/src/AttributeOptions/Importer.php b/src/AttributeOptions/Importer.php index 0c67bf6b..092fef14 100644 --- a/src/AttributeOptions/Importer.php +++ b/src/AttributeOptions/Importer.php @@ -10,14 +10,22 @@ use DateTime; use Sylius\Component\Attribute\AttributeType\SelectAttributeType; use Sylius\Component\Product\Model\ProductAttributeInterface; +use Sylius\Component\Product\Model\ProductOptionInterface; +use Sylius\Component\Product\Model\ProductOptionTranslationInterface; +use Sylius\Component\Product\Model\ProductOptionValueInterface; +use Sylius\Component\Product\Model\ProductOptionValueTranslationInterface; +use Sylius\Component\Product\Repository\ProductOptionRepositoryInterface; +use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; +use Sylius\Component\Resource\Translation\Provider\TranslationLocaleProviderInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Webgriffe\SyliusAkeneoPlugin\Event\IdentifiersModifiedSinceSearchBuilderBuiltEvent; use Webgriffe\SyliusAkeneoPlugin\ImporterInterface; +use Webmozart\Assert\Assert; /** - * @phpstan-type AkeneoAttribute array{code: string, type: string} - * @phpstan-type AkeneoAttributeOption array{_links: array, code: string, attribute: string, sort_order: int, labels: array} + * @psalm-type AkeneoAttribute array{code: string, type: string} + * @psalm-type AkeneoAttributeOption array{_links: array, code: string, attribute: string, sort_order: int, labels: array} */ final class Importer implements ImporterInterface { @@ -27,12 +35,47 @@ final class Importer implements ImporterInterface /** * @param RepositoryInterface $attributeRepository + * @param ?FactoryInterface $productOptionValueTranslationFactory + * @param ?FactoryInterface $productOptionValueFactory + * @param ?FactoryInterface $productOptionTranslationFactory */ public function __construct( private AkeneoPimClientInterface $apiClient, private RepositoryInterface $attributeRepository, private EventDispatcherInterface $eventDispatcher, + private ?ProductOptionRepositoryInterface $optionRepository = null, + private ?TranslationLocaleProviderInterface $translationLocaleProvider = null, + private ?FactoryInterface $productOptionValueTranslationFactory = null, + private ?FactoryInterface $productOptionValueFactory = null, + private ?FactoryInterface $productOptionTranslationFactory = null, ) { + if ($this->optionRepository === null) { + trigger_deprecation( + 'webgriffe/sylius-akeneo-plugin', + 'v2.2.0', + 'Not passing a "%s" instance to "%s" constructor is deprecated and will not be possible anymore in the next major version.', + ProductOptionRepositoryInterface::class, + self::class, + ); + } + if ($this->translationLocaleProvider === null) { + trigger_deprecation( + 'webgriffe/sylius-akeneo-plugin', + 'v2.2.0', + 'Not passing a "%s" instance to "%s" constructor is deprecated and will not be possible anymore in the next major version.', + TranslationLocaleProviderInterface::class, + self::class, + ); + } + if ($this->productOptionValueTranslationFactory === null) { + trigger_deprecation( + 'webgriffe/sylius-akeneo-plugin', + 'v2.2.0', + 'Not passing a "%s" instance to "%s" constructor is deprecated and will not be possible anymore in the next major version.', + FactoryInterface::class, + self::class, + ); + } } public function getAkeneoEntity(): string @@ -44,8 +87,18 @@ public function import(string $identifier): void { $attribute = $this->attributeRepository->findOneBy(['code' => $identifier]); if (null !== $attribute && $attribute->getType() === SelectAttributeType::TYPE) { - $this->importAttribute($identifier, $attribute); + $this->importAttributeConfiguration($identifier, $attribute); + } + $optionRepository = $this->optionRepository; + if (!$optionRepository instanceof ProductOptionRepositoryInterface) { + return; + } + $option = $optionRepository->findOneBy(['code' => $identifier]); + if (!$option instanceof ProductOptionInterface) { + return; } + $this->updateProductOption($option); + $this->importOptionValues($identifier, $option); } /** @@ -64,7 +117,10 @@ public function getIdentifiersModifiedSince(DateTime $sinceDate): array /** @var ResourceCursorInterface $akeneoAttributes */ $akeneoAttributes = $this->apiClient->getAttributeApi()->all(50, ['search' => $searchBuilder->getFilters()]); - return $this->filterBySyliusAttributeCodes($akeneoAttributes); + return array_merge( + $this->filterBySyliusAttributeCodes($akeneoAttributes), + $this->filterSyliusOptionCodes($akeneoAttributes), + ); } /** @@ -97,21 +153,38 @@ private function filterBySyliusAttributeCodes(ResourceCursorInterface $akeneoAtt return $attributeCodes; } - private function importAttribute(string $attributeCode, ProductAttributeInterface $attribute): void + /** + * Return the list of Akeneo attribute codes whose code is used as a code for a Sylius attribute + * + * @param ResourceCursorInterface $akeneoAttributes + * + * @return string[] + */ + private function filterSyliusOptionCodes(ResourceCursorInterface $akeneoAttributes): array { - $attributeOptionsOrdered = []; - /** @var ResourceCursorInterface $attributeOptions */ - $attributeOptions = $this->apiClient->getAttributeOptionApi()->all($attributeCode); - foreach ($attributeOptions as $attributeOption) { - $attributeOptionsOrdered[] = $attributeOption; + $productOptionRepository = $this->optionRepository; + if (!$productOptionRepository instanceof ProductOptionRepositoryInterface) { + return []; } - usort( - $attributeOptionsOrdered, - static fn (array $option1, array $option2): int => $option1['sort_order'] <=> $option2['sort_order'], + $akeneoAttributeCodes = []; + foreach ($akeneoAttributes as $akeneoAttribute) { + $akeneoAttributeCodes[] = $akeneoAttribute['code']; + } + $syliusOptions = $productOptionRepository->findByCodes($akeneoAttributeCodes); + + return array_map( + static fn (ProductOptionInterface $option): string => (string) $option->getCode(), + $syliusOptions, ); + } + + private function importAttributeConfiguration(string $attributeCode, ProductAttributeInterface $attribute): void + { /** @var array{choices: array>, multiple: bool, min: ?int, max: ?int} $configuration */ $configuration = $attribute->getConfiguration(); - $configuration['choices'] = $this->convertAkeneoAttributeOptionsIntoSyliusChoices($attributeOptionsOrdered); + $configuration['choices'] = $this->convertAkeneoAttributeOptionsIntoSyliusChoices( + $this->getSortedAkeneoAttributeOptionsByAttributeCode($attributeCode), + ); $attribute->setConfiguration($configuration); $this->attributeRepository->add($attribute); @@ -126,9 +199,106 @@ private function convertAkeneoAttributeOptionsIntoSyliusChoices(array $attribute { $choices = []; foreach ($attributeOptions as $attributeOption) { - $choices[$attributeOption['code']] = $attributeOption['labels']; + $attributeOptionLabelsNotNull = array_filter( + $attributeOption['labels'], + static fn (?string $label): bool => $label !== null, + ); + $choices[$attributeOption['code']] = $attributeOptionLabelsNotNull; } return $choices; } + + private function importOptionValues(string $attributeCode, ProductOptionInterface $option): void + { + $attributeOptions = $this->getSortedAkeneoAttributeOptionsByAttributeCode($attributeCode); + + foreach ($attributeOptions as $attributeOption) { + $optionValueCode = $attributeCode . '_' . $attributeOption['code']; + $optionValue = null; + foreach ($option->getValues() as $value) { + if ($value->getCode() === $optionValueCode) { + $optionValue = $value; + + break; + } + } + if ($optionValue === null) { + // We can assume that if we are here is because the option repository has been injected, so event this factory should be! + $productOptionValueFactory = $this->productOptionValueFactory; + Assert::isInstanceOf($productOptionValueFactory, FactoryInterface::class); + $optionValue = $productOptionValueFactory->createNew(); + // TODO handle translations + $optionValue->setCode($optionValueCode); + $option->addValue($optionValue); + } + + // We can assume that if we are here is because the option repository has been injected, so event these services should be! + $translationLocaleProvider = $this->translationLocaleProvider; + Assert::isInstanceOf($translationLocaleProvider, TranslationLocaleProviderInterface::class); + $definedLocalesCodes = $translationLocaleProvider->getDefinedLocalesCodes(); + + $productOptionValueTranslationFactory = $this->productOptionValueTranslationFactory; + Assert::isInstanceOf($productOptionValueTranslationFactory, FactoryInterface::class); + + foreach ($attributeOption['labels'] as $localeCode => $label) { + if (!in_array($localeCode, $definedLocalesCodes, true)) { + continue; + } + $optionValueTranslation = $optionValue->getTranslation($localeCode); + if ($optionValueTranslation->getLocale() !== $localeCode) { + $optionValueTranslation = $productOptionValueTranslationFactory->createNew(); + $optionValueTranslation->setLocale($localeCode); + } + $optionValueTranslation->setValue($label ?? $optionValue->getCode()); + if (!$optionValue->hasTranslation($optionValueTranslation)) { + $optionValue->addTranslation($optionValueTranslation); + } + } + } + } + + /** + * @return array + */ + private function getSortedAkeneoAttributeOptionsByAttributeCode(string $attributeCode): array + { + $attributeOptionsOrdered = []; + /** @var ResourceCursorInterface $attributeOptions */ + $attributeOptions = $this->apiClient->getAttributeOptionApi()->all($attributeCode); + foreach ($attributeOptions as $attributeOption) { + $attributeOptionsOrdered[] = $attributeOption; + } + usort( + $attributeOptionsOrdered, + static fn (array $option1, array $option2): int => $option1['sort_order'] <=> $option2['sort_order'], + ); + + return $attributeOptionsOrdered; + } + + private function updateProductOption(ProductOptionInterface $productOption): void + { + // TODO: Update also the position of the option? The problem is that this position is on family variant entity! + $productOptionCode = $productOption->getCode(); + Assert::notNull($productOptionCode); + + // We can assume that if we are here is because the option repository has been injected, so event this factory should be! + $productOptionTranslationFactory = $this->productOptionTranslationFactory; + Assert::isInstanceOf($productOptionTranslationFactory, FactoryInterface::class); + + $attributeResponse = $this->apiClient->getAttributeApi()->get($productOptionCode); + foreach ($attributeResponse['labels'] as $locale => $label) { + $productOptionTranslation = $productOption->getTranslation($locale); + if ($productOptionTranslation->getLocale() === $locale) { + $productOptionTranslation->setName($label); + + continue; + } + $newProductOptionTranslation = $productOptionTranslationFactory->createNew(); + $newProductOptionTranslation->setLocale($locale); + $newProductOptionTranslation->setName($label); + $productOption->addTranslation($newProductOptionTranslation); + } + } } diff --git a/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_retaining_sort_order.yaml b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_to_sylius_attribute_retaining_sort_order.yaml similarity index 100% rename from tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_retaining_sort_order.yaml rename to tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_to_sylius_attribute_retaining_sort_order.yaml diff --git a/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_to_sylius_option.yaml b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_to_sylius_option.yaml new file mode 100644 index 00000000..c3c3b495 --- /dev/null +++ b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_import_all_options_from_akeneo_to_sylius_option.yaml @@ -0,0 +1,9 @@ +Sylius\Component\Locale\Model\Locale: + en_US: + code: "en_US" + it_IT: + code: "it_IT" + +Sylius\Component\Product\Model\ProductOption: + size: + code: 'size' diff --git a/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes.yaml b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes_or_sylius_product_options.yaml similarity index 71% rename from tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes.yaml rename to tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes_or_sylius_product_options.yaml index d7467039..f57c393a 100644 --- a/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes.yaml +++ b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes_or_sylius_product_options.yaml @@ -5,7 +5,11 @@ Sylius\Component\Locale\Model\Locale: code: "it_IT" Sylius\Component\Product\Model\ProductAttribute: - finitura: + material: code: 'material' type: 'select' storageType: 'json' + +Sylius\Component\Product\Model\ProductOption: + size: + code: 'size' diff --git a/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_updates_all_options_from_akeneo_to_sylius_option.yaml b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_updates_all_options_from_akeneo_to_sylius_option.yaml new file mode 100644 index 00000000..7133fb57 --- /dev/null +++ b/tests/DataFixtures/ORM/resources/Importer/AttributeOptions/it_updates_all_options_from_akeneo_to_sylius_option.yaml @@ -0,0 +1,54 @@ +Sylius\Component\Locale\Model\Locale: + en_US: + code: 'en_US' + it_IT: + code: 'it_IT' + +Sylius\Component\Product\Model\ProductOption: + size: + code: 'size' + translations: + - '@size_en_US' + - '@size_it_IT' + +Sylius\Component\Product\Model\ProductOptionTranslation: + size_en_US: + name: 'Format' + locale: 'en_US' + translatable: '@size' + size_it_IT: + name: 'Formato' + locale: 'it_IT' + translatable: '@size' + +Sylius\Component\Product\Model\ProductOptionValue: + small: + code: 'size_small' + option: '@size' + translations: + - '@small_it_IT' + - '@small_en_US' + large: + code: 'size_large' + option: '@size' + translations: + - '@large_it_IT' + - '@large_en_US' + +Sylius\Component\Product\Model\ProductOptionValueTranslation: + small_it_IT: + value: 'S' + locale: 'it_IT' + translatable: '@small' + small_en_US: + value: 'S' + locale: 'en_US' + translatable: '@small' + large_it_IT: + value: 'L' + locale: 'it_IT' + translatable: '@large' + large_en_US: + value: 'L' + locale: 'en_US' + translatable: '@large' diff --git a/tests/InMemory/Client/Api/InMemoryAttributeOptionApi.php b/tests/InMemory/Client/Api/InMemoryAttributeOptionApi.php index fd484abf..3d1f65f4 100644 --- a/tests/InMemory/Client/Api/InMemoryAttributeOptionApi.php +++ b/tests/InMemory/Client/Api/InMemoryAttributeOptionApi.php @@ -62,7 +62,7 @@ public function __construct(private ArrayIterator $iterator, private int $pageSi { } - public function current() + public function current(): mixed { return $this->iterator->current(); } diff --git a/tests/Integration/AttributeOptions/ImporterTest.php b/tests/Integration/AttributeOptions/ImporterTest.php index 6d0c7c13..99bc715c 100644 --- a/tests/Integration/AttributeOptions/ImporterTest.php +++ b/tests/Integration/AttributeOptions/ImporterTest.php @@ -7,6 +7,9 @@ use DateTime; use Fidry\AliceDataFixtures\Persistence\PurgeMode; use Sylius\Component\Product\Model\ProductAttributeInterface; +use Sylius\Component\Product\Model\ProductOptionInterface; +use Sylius\Component\Product\Model\ProductOptionValueInterface; +use Sylius\Component\Product\Repository\ProductOptionRepositoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Tests\Webgriffe\SyliusAkeneoPlugin\DataFixtures\DataFixture; @@ -23,15 +26,25 @@ final class ImporterTest extends KernelTestCase private RepositoryInterface $attributeRepository; + private ProductOptionRepositoryInterface $optionRepository; + protected function setUp(): void { self::bootKernel(); $this->importer = self::getContainer()->get('webgriffe_sylius_akeneo.attribute_options.importer'); $fixtureLoader = self::getContainer()->get('fidry_alice_data_fixtures.loader.doctrine'); $this->attributeRepository = self::getContainer()->get('sylius.repository.product_attribute'); + $this->optionRepository = self::getContainer()->get('sylius.repository.product_option'); InMemoryAttributeApi::addResource(new Attribute('material', AttributeType::SIMPLE_SELECT)); InMemoryAttributeApi::addResource(new Attribute('text_attribute', AttributeType::TEXT)); + InMemoryAttributeApi::addResource(Attribute::create('size', [ + 'type' => AttributeType::SIMPLE_SELECT, + 'labels' => [ + 'en_US' => 'Size', + 'it_IT' => 'Taglia', + ], + ])); InMemoryAttributeOptionApi::addResource(new AttributeOption('cotton', 'material', 5, [ 'en_US' => 'cotton', @@ -53,6 +66,14 @@ protected function setUp(): void 'en_US' => 'leather', 'it_IT' => 'cuoio', ])); + InMemoryAttributeOptionApi::addResource(new AttributeOption('small', 'size', 1, [ + 'en_US' => 'Small', + 'it_IT' => 'Piccola', + ])); + InMemoryAttributeOptionApi::addResource(new AttributeOption('large', 'size', 2, [ + 'en_US' => 'Large', + 'it_IT' => 'Grande', + ])); $ORMResourceFixturePath = DataFixture::path . '/ORM/resources/Importer/AttributeOptions/' . $this->getName() . '.yaml'; if (file_exists($ORMResourceFixturePath)) { @@ -92,7 +113,7 @@ public function it_does_nothing_if_attribute_is_not_a_select_attribute(): void /** * @test */ - public function it_import_all_options_from_akeneo_retaining_sort_order(): void + public function it_import_all_options_from_akeneo_to_sylius_attribute_retaining_sort_order(): void { $this->importer->import('material'); @@ -115,10 +136,72 @@ public function it_import_all_options_from_akeneo_retaining_sort_order(): void /** * @test */ - public function it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes(): void + public function it_import_all_options_from_akeneo_to_sylius_option(): void + { + $this->importer->import('size'); + + $option = $this->optionRepository->findOneBy(['code' => 'size']); + $this->assertInstanceOf(ProductOptionInterface::class, $option); + $this->assertEquals('Size', $option->getTranslation('en_US')->getName()); + $this->assertEquals('Taglia', $option->getTranslation('it_IT')->getName()); + $optionValues = $option->getValues(); + $this->assertCount(2, $optionValues); + + $smallOptionValues = $optionValues->filter(static fn (ProductOptionValueInterface $optionValue): bool => $optionValue->getCode() === 'size_small'); + $this->assertCount(1, $smallOptionValues); + $smallOptionValue = $smallOptionValues->first(); + $this->assertInstanceOf(ProductOptionValueInterface::class, $smallOptionValue); + $this->assertCount(2, $smallOptionValue->getTranslations()); + $this->assertEquals('Small', $smallOptionValue->getTranslation('en_US')->getValue()); + $this->assertEquals('Piccola', $smallOptionValue->getTranslation('it_IT')->getValue()); + + $smallOptionValues = $optionValues->filter(static fn (ProductOptionValueInterface $optionValue): bool => $optionValue->getCode() === 'size_large'); + $this->assertCount(1, $smallOptionValues); + $smallOptionValue = $smallOptionValues->first(); + $this->assertInstanceOf(ProductOptionValueInterface::class, $smallOptionValue); + $this->assertCount(2, $smallOptionValue->getTranslations()); + $this->assertEquals('Large', $smallOptionValue->getTranslation('en_US')->getValue()); + $this->assertEquals('Grande', $smallOptionValue->getTranslation('it_IT')->getValue()); + } + + /** + * @test + */ + public function it_updates_all_options_from_akeneo_to_sylius_option(): void + { + $this->importer->import('size'); + + $option = $this->optionRepository->findOneBy(['code' => 'size']); + $this->assertInstanceOf(ProductOptionInterface::class, $option); + $this->assertEquals('Size', $option->getTranslation('en_US')->getName()); + $this->assertEquals('Taglia', $option->getTranslation('it_IT')->getName()); + $optionValues = $option->getValues(); + $this->assertCount(2, $optionValues); + + $smallOptionValues = $optionValues->filter(static fn (ProductOptionValueInterface $optionValue): bool => $optionValue->getCode() === 'size_small'); + $this->assertCount(1, $smallOptionValues); + $smallOptionValue = $smallOptionValues->first(); + $this->assertInstanceOf(ProductOptionValueInterface::class, $smallOptionValue); + $this->assertCount(2, $smallOptionValue->getTranslations()); + $this->assertEquals('Small', $smallOptionValue->getTranslation('en_US')->getValue()); + $this->assertEquals('Piccola', $smallOptionValue->getTranslation('it_IT')->getValue()); + + $smallOptionValues = $optionValues->filter(static fn (ProductOptionValueInterface $optionValue): bool => $optionValue->getCode() === 'size_large'); + $this->assertCount(1, $smallOptionValues); + $smallOptionValue = $smallOptionValues->first(); + $this->assertInstanceOf(ProductOptionValueInterface::class, $smallOptionValue); + $this->assertCount(2, $smallOptionValue->getTranslations()); + $this->assertEquals('Large', $smallOptionValue->getTranslation('en_US')->getValue()); + $this->assertEquals('Grande', $smallOptionValue->getTranslation('it_IT')->getValue()); + } + + /** + * @test + */ + public function it_returns_all_simple_select_and_multiselect_attributes_identifiers_that_are_also_sylius_select_attributes_or_sylius_product_options(): void { $identifiers = $this->importer->getIdentifiersModifiedSince(new DateTime()); - $this->assertEquals(['material'], $identifiers); + $this->assertEquals(['material', 'size'], $identifiers); } }