diff --git a/README.md b/README.md index e35ecdf7..5233c7f9 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ fos_sylius_import_export: * customer (csv, excel, json) * product (csv) -## Example import files +### Example import files See the fixtures in the Behat tests: `tests/Behat/Resources/fixtures` @@ -144,6 +144,24 @@ admin overview panel using the event hook system, ie. `admin/tax-categories/`. $ bin/console sylius:export-to-message-queue country +### Product (and variants) import + +The `tests/Behat/Resources/fixtures/products_and_variants.csv` file shows an example on how products and its variants +can be managed in the CSV file. + +The product options need to exist already, they won't be created automatically. + +The main idea is using the `Parent_Code` column which makes the connection from a product variant to its main product. +If no `Parent_Code` is set, it simply creates a new product entry. + +Product variants rows don't need to repeat the common main product data such as `Name`, `Description` or the product attributes +because they will be taken from the main product. + +To create the different product variants by defining the product options, +you need to add a column with the prefix `Product_Option_[PRODUCT_OPTION_CODE]` and fill in the correct option values. + +Optionally, you can also set the corresponding tax category code in the `tax_category_code` column. + ## Development ### Adding new importer types diff --git a/node_modules b/node_modules deleted file mode 120000 index 9270531f..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -tests/Application/node_modules \ No newline at end of file diff --git a/src/Processor/ProductProcessor.php b/src/Processor/ProductProcessor.php index 859340ca..fb44fe37 100644 --- a/src/Processor/ProductProcessor.php +++ b/src/Processor/ProductProcessor.php @@ -4,7 +4,9 @@ namespace FriendsOfSylius\SyliusImportExportPlugin\Processor; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; +use FriendsOfSylius\SyliusImportExportPlugin\Exception\ImporterException; use FriendsOfSylius\SyliusImportExportPlugin\Importer\Transformer\TransformerPoolInterface; use FriendsOfSylius\SyliusImportExportPlugin\Repository\ProductImageRepositoryInterface; use FriendsOfSylius\SyliusImportExportPlugin\Service\AttributeCodesProviderInterface; @@ -24,14 +26,18 @@ use Sylius\Component\Product\Generator\SlugGeneratorInterface; use Sylius\Component\Product\Model\ProductAttribute; use Sylius\Component\Product\Model\ProductAttributeValueInterface; +use Sylius\Component\Product\Model\ProductOptionValueInterface; use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; +use Sylius\Component\Taxation\Model\TaxCategory; +use Sylius\Component\Taxation\Repository\TaxCategoryRepositoryInterface; use Sylius\Component\Taxonomy\Factory\TaxonFactoryInterface; use Sylius\Component\Taxonomy\Repository\TaxonRepositoryInterface; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; final class ProductProcessor implements ResourceProcessorInterface { + private const COLUMN_PRODUCT_OPTION_PREFIX = "Product_Option_"; + /** @var RepositoryInterface */ private $channelPricingRepository; /** @var FactoryInterface */ @@ -60,8 +66,6 @@ final class ProductProcessor implements ResourceProcessorInterface private $productRepository; /** @var TaxonRepositoryInterface */ private $taxonRepository; - /** @var PropertyAccessorInterface */ - private $propertyAccessor; /** @var MetadataValidatorInterface */ private $metadataValidator; /** @var array */ @@ -81,7 +85,8 @@ final class ProductProcessor implements ResourceProcessorInterface /** @var FactoryInterface */ private $productVariantFactory; /** @var RepositoryInterface */ - private $productVariantRepository; + private $productOptionValueRepository; + private TaxCategoryRepositoryInterface $taxCategoryRepository; public function __construct( ProductFactoryInterface $productFactory, @@ -89,7 +94,6 @@ public function __construct( ProductRepositoryInterface $productRepository, TaxonRepositoryInterface $taxonRepository, MetadataValidatorInterface $metadataValidator, - PropertyAccessorInterface $propertyAccessor, RepositoryInterface $productAttributeRepository, AttributeCodesProviderInterface $attributeCodesProvider, FactoryInterface $productAttributeValueFactory, @@ -100,11 +104,12 @@ public function __construct( FactoryInterface $channelPricingFactory, ProductTaxonRepository $productTaxonRepository, ProductImageRepositoryInterface $productImageRepository, - RepositoryInterface $productVariantRepository, + RepositoryInterface $productOptionValueRepository, RepositoryInterface $channelPricingRepository, ImageTypesProviderInterface $imageTypesProvider, SlugGeneratorInterface $slugGenerator, ?TransformerPoolInterface $transformerPool, + TaxCategoryRepositoryInterface $taxCategoryRepository, EntityManagerInterface $manager, array $headerKeys ) { @@ -113,7 +118,6 @@ public function __construct( $this->productRepository = $productRepository; $this->taxonRepository = $taxonRepository; $this->metadataValidator = $metadataValidator; - $this->propertyAccessor = $propertyAccessor; $this->productAttributeRepository = $productAttributeRepository; $this->productAttributeValueFactory = $productAttributeValueFactory; $this->attributeCodesProvider = $attributeCodesProvider; @@ -128,84 +132,260 @@ public function __construct( $this->productImageRepository = $productImageRepository; $this->imageTypesProvider = $imageTypesProvider; $this->productVariantFactory = $productVariantFactory; - $this->productVariantRepository = $productVariantRepository; + $this->productOptionValueRepository = $productOptionValueRepository; $this->channelPricingFactory = $channelPricingFactory; $this->channelPricingRepository = $channelPricingRepository; + $this->taxCategoryRepository = $taxCategoryRepository; } /** * {@inheritdoc} + * @throws ImporterException */ public function process(array $data): void + { + $this->loadAttributeCodes(); + $this->mergeHeaderKeys(); + $this->metadataValidator->validateHeaders($this->headerKeys, $data); + + if ($this->isProductVariant($data)) { + $mainProduct = $this->loadProductFromParentCode($data); + $this->setVariantAndOptions($mainProduct, $data); + $this->productRepository->add($mainProduct); + return; + } + + /** @var ProductInterface $mainProduct */ + $mainProduct = $this->productRepository->findOneByCode($data['Code']); + if (null === $mainProduct) { + $mainProduct = $this->resourceProductFactory->createNew(); + } + + $mainProduct->setCode($data['Code']); + $this->setDetails($mainProduct, $data); + $this->setVariantAndOptions($mainProduct, $data); + $this->setAttributesData($mainProduct, $data); + $this->setMainTaxon($mainProduct, $data); + $this->setTaxons($mainProduct, $data); + $this->setChannel($mainProduct, $data); + $this->setImage($mainProduct, $data); + + $this->productRepository->add($mainProduct); + } + + private function loadAttributeCodes() { $this->attrCode = $this->attributeCodesProvider->getAttributeCodesList(); - $this->imageCode = $this->imageTypesProvider->getProductImagesCodesWithPrefixList(); + } - $this->headerKeys = \array_merge($this->headerKeys, $this->attrCode); + private function mergeHeaderKeys() + { + $this->imageCode = $this->imageTypesProvider->getProductImagesCodesWithPrefixList(); $this->headerKeys = \array_merge($this->headerKeys, $this->imageCode); - $this->metadataValidator->validateHeaders($this->headerKeys, $data); + } - $product = $this->getProduct($data['Code']); + private function isProductVariant(array $data): bool + { + return !empty($data['Parent_Code']); + } - $this->setDetails($product, $data); - $this->setVariant($product, $data); - $this->setAttributesData($product, $data); - $this->setMainTaxon($product, $data); - $this->setTaxons($product, $data); - $this->setChannel($product, $data); - $this->setImage($product, $data); + private function loadProductFromParentCode(array $data): ProductInterface + { + $mainProduct = $this->productRepository->findOneByCode($data['Parent_Code']); + if (null === $mainProduct) { + throw new ImporterException( + "Parent Product with code {$data['Parent_Code']} does not exist yet. Create it first." + ); + } + return $mainProduct; + } - $this->productRepository->add($product); + private function setTaxCategory(ProductVariantInterface $productVariant, array $data) + { + /** @var TaxCategory $taxCategory */ + $taxCategory = $this->taxCategoryRepository->findOneBy(['code' => $data['tax_category_code']]); + + if ($taxCategory) { + $productVariant->setTaxCategory($taxCategory); + } } - private function getProduct(string $code): ProductInterface + /** + * @throws ImporterException + */ + private function setVariantAndOptions(ProductInterface $product, array $data): void { - /** @var ProductInterface|null $product */ - $product = $this->productRepository->findOneBy(['code' => $code]); - if (null === $product) { - /** @var ProductInterface $product */ - $product = $this->resourceProductFactory->createNew(); - $product->setCode($code); + $productOptionValueList = $this->getProductOptionValueListFromData($data); + $productVariant = $this->getOrCreateProductVariant($product, $productOptionValueList); + + $productVariant->setCode($data['Code']); + $productVariant->setCurrentLocale( + $this->getVariantLocale($data, $product) + ); + $productVariant->setName( + $this->generateVariantName($data, $product->getName()) + ); + $this->setTaxCategory($productVariant, $data); + + /** @var ProductOptionValueInterface $productOptionValue */ + foreach ($productOptionValueList as $productOptionValue) { + $product->addOption($productOptionValue->getOption()); + $productVariant->addOptionValue($productOptionValue); } - return $product; + $channels = \explode('|', $data['Channels']); + foreach ($channels as $channelCode) { + /** @var ChannelPricingInterface|null $channelPricing */ + $channelPricing = $this->channelPricingRepository->findOneBy([ + 'channelCode' => $channelCode, + 'productVariant' => $productVariant, + ]); + + if (null === $channelPricing) { + /** @var ChannelPricingInterface $channelPricing */ + $channelPricing = $this->channelPricingFactory->createNew(); + $channelPricing->setChannelCode($channelCode); + $productVariant->addChannelPricing($channelPricing); + } + + $channelPricing->setPrice((int)$data['Price']); + $channelPricing->setOriginalPrice((int)$data['Price']); + } + + $product->addVariant($productVariant); + } + + private function getProductOptionValueListFromData(array $data): array + { + $productOptionValues = array_values( + $this->getProductOptionColumnsFromData($data) + ); + $productOptionValueList = array_map( + function (string $optionValueCode) { + // Empty string values should be allowed + // Don't throw an exception for this case + if ('' === $optionValueCode) { + return null; + } + + /** @var $productOptionValue ProductOptionValueInterface */ + $productOptionValue = $this->productOptionValueRepository->findOneBy( + ['code' => $optionValueCode] + ); + + if (null === $productOptionValue) { + throw new ImporterException( + "Product option value with code: $optionValueCode does not exist." + ); + } + return $productOptionValue; + }, + $productOptionValues + ); + + return array_filter($productOptionValueList); } - private function getProductVariant(string $code): ProductVariantInterface + private function getProductOptionColumnsFromData(array $data): array { - /** @var ProductVariantInterface|null $productVariant */ - $productVariant = $this->productVariantRepository->findOneBy(['code' => $code]); - if ($productVariant === null) { - /** @var ProductVariantInterface $productVariant */ + return array_filter( + $data, + function ($key) { + return strpos($key, self::COLUMN_PRODUCT_OPTION_PREFIX) === 0; + }, + ARRAY_FILTER_USE_KEY + ); + } + + private function getOrCreateProductVariant( + ProductInterface $product, + array $productOptionValueListFromData + ): ProductVariantInterface { + $productVariant = null; + $productVariants = $product->getVariants(); + + if ($productVariants->isEmpty()) { $productVariant = $this->productVariantFactory->createNew(); - $productVariant->setCode($code); + } else { + $productVariant = $this->findProductVariant( + $productVariants, + $productOptionValueListFromData, + ); + + if (null === $productVariant) { + $productVariant = $this->productVariantFactory->createNew(); + } } return $productVariant; } - private function setMainTaxon(ProductInterface $product, array $data): void - { - /** @var Taxon|null $taxon */ - $taxon = $this->taxonRepository->findOneBy(['code' => $data['Main_taxon']]); - if ($taxon === null) { - return; + /** + * Looks up the appropriate Product variant with the given option + * values. + * + * @param Collection $productVariants ProductVariantInterface collection + * @param array $productOptionValueListFromData A list of ProductOptionValueInterface objects + * @return ProductVariantInterface $productVariant The found product variant or null + */ + private function findProductVariant( + Collection $productVariants, + array $productOptionValueListFromData + ): ?ProductVariantInterface { + /** @var ProductVariantInterface $productVariantItem */ + foreach ($productVariants as $productVariantItem) { + $productVariantOptionValueCheckResult = array_map( + function (ProductOptionValueInterface $optionValue) use ( + $productVariantItem + ) { + return $productVariantItem->hasOptionValue( + $optionValue + ); + }, + $productOptionValueListFromData + ); + + $productVariantFound = array_product( + $productVariantOptionValueCheckResult + ); + + if ($productVariantFound) { + return $productVariantItem; + } } - /** @var ProductInterface $product */ - $product->setMainTaxon($taxon); + return null; + } - $this->addTaxonToProduct($product, $data['Main_taxon']); + private function getVariantLocale(array $data, ProductInterface $product): string + { + return $data['Locale'] ?: $product->getTranslation()->getLocale(); } - private function setTaxons(ProductInterface $product, array $data): void + private function generateVariantName(array $data, $fallbackName): string { - $taxonCodes = \explode('|', $data['Taxons']); - foreach ($taxonCodes as $taxonCode) { - if ($taxonCode !== $data['Main_taxon']) { - $this->addTaxonToProduct($product, $taxonCode); - } - } + $variantName = substr($data['Name'], 0, 255); + return $variantName ?: $fallbackName; + } + + private function setDetails(ProductInterface $product, array $data): void + { + $product->setCurrentLocale($data['Locale']); + $product->setFallbackLocale($data['Locale']); + + $product->setName(substr($data['Name'], 0, 255)); + $product->setEnabled((bool)$data['Enabled']); + $product->setDescription($data['Description']); + $product->setShortDescription( + substr($data['Short_description'], 0, 255) + ); + $product->setMetaDescription(substr($data['Meta_description'], 0, 255)); + $product->setMetaKeywords(substr($data['Meta_keywords'], 0, 255)); + $product->setSlug( + $product->getSlug() ?: $this->slugGenerator->generate( + "{$product->getCode()}-{$product->getName()}" + ) + ); } private function setAttributesData(ProductInterface $product, array $data): void @@ -238,52 +418,15 @@ private function setAttributesData(ProductInterface $product, array $data): void } } - private function setDetails(ProductInterface $product, array $data): void - { - $product->setCurrentLocale($data['Locale']); - $product->setFallbackLocale($data['Locale']); - - $product->setName(substr($data['Name'], 0, 255)); - $product->setEnabled((bool) $data['Enabled']); - $product->setDescription($data['Description']); - $product->setShortDescription(substr($data['Short_description'], 0, 255)); - $product->setMetaDescription(substr($data['Meta_description'], 0, 255)); - $product->setMetaKeywords(substr($data['Meta_keywords'], 0, 255)); - $product->setSlug($product->getSlug() ?: $this->slugGenerator->generate($product->getName())); - } - - private function setVariant(ProductInterface $product, array $data): void - { - $productVariant = $this->getProductVariant($product->getCode()); - $productVariant->setCurrentLocale($data['Locale']); - $productVariant->setName(substr($data['Name'], 0, 255)); - - $channels = \explode('|', $data['Channels']); - foreach ($channels as $channelCode) { - /** @var ChannelPricingInterface|null $channelPricing */ - $channelPricing = $this->channelPricingRepository->findOneBy([ - 'channelCode' => $channelCode, - 'productVariant' => $productVariant, - ]); - - if (null === $channelPricing) { - /** @var ChannelPricingInterface $channelPricing */ - $channelPricing = $this->channelPricingFactory->createNew(); - $channelPricing->setChannelCode($channelCode); - $productVariant->addChannelPricing($channelPricing); - } - - $channelPricing->setPrice((int) $data['Price']); - $channelPricing->setOriginalPrice((int) $data['Price']); - } - - $product->addVariant($productVariant); - } - - private function setAttributeValue(ProductInterface $product, array $data, string $attrCode): void - { + private function setAttributeValue( + ProductInterface $product, + array $data, + string $attrCode + ): void { /** @var ProductAttribute $productAttr */ - $productAttr = $this->productAttributeRepository->findOneBy(['code' => $attrCode]); + $productAttr = $this->productAttributeRepository->findOneBy( + ['code' => $attrCode] + ); /** @var ProductAttributeValueInterface $attr */ $attr = $this->productAttributeValueFactory->createNew(); $attr->setAttribute($productAttr); @@ -291,7 +434,10 @@ private function setAttributeValue(ProductInterface $product, array $data, strin $attr->setLocaleCode($product->getTranslation()->getLocale()); if (null !== $this->transformerPool) { - $data[$attrCode] = $this->transformerPool->handle($productAttr->getType(), $data[$attrCode]); + $data[$attrCode] = $this->transformerPool->handle( + $productAttr->getType(), + $data[$attrCode] + ); } $attr->setValue($data[$attrCode]); @@ -299,21 +445,25 @@ private function setAttributeValue(ProductInterface $product, array $data, strin $this->manager->persist($attr); } - private function setChannel(ProductInterface $product, array $data): void + private function setMainTaxon(ProductInterface $product, array $data): void { - $channels = \explode('|', $data['Channels']); - foreach ($channels as $channelCode) { - /** @var ChannelInterface|null $channel */ - $channel = $this->channelRepository->findOneBy(['code' => $channelCode]); - if ($channel === null) { - continue; - } - $product->addChannel($channel); + /** @var Taxon|null $taxon */ + $taxon = $this->taxonRepository->findOneBy( + ['code' => $data['Main_taxon']] + ); + if ($taxon === null) { + return; } + + $product->setMainTaxon($taxon); + + $this->addTaxonToProduct($product, $data['Main_taxon']); } - private function addTaxonToProduct(ProductInterface $product, string $taxonCode): void - { + private function addTaxonToProduct( + ProductInterface $product, + string $taxonCode + ): void { /** @var Taxon|null $taxon */ $taxon = $this->taxonRepository->findOneBy(['code' => $taxonCode]); if ($taxon === null) { @@ -335,6 +485,31 @@ private function addTaxonToProduct(ProductInterface $product, string $taxonCode) $product->addProductTaxon($productTaxon); } + private function setTaxons(ProductInterface $product, array $data): void + { + $taxonCodes = \explode('|', $data['Taxons']); + foreach ($taxonCodes as $taxonCode) { + if ($taxonCode !== $data['Main_taxon']) { + $this->addTaxonToProduct($product, $taxonCode); + } + } + } + + private function setChannel(ProductInterface $product, array $data): void + { + $channels = \explode('|', $data['Channels']); + foreach ($channels as $channelCode) { + /** @var ChannelInterface|null $channel */ + $channel = $this->channelRepository->findOneBy( + ['code' => $channelCode] + ); + if ($channel === null) { + continue; + } + $product->addChannel($channel); + } + } + private function setImage(ProductInterface $product, array $data): void { $productImageCodes = $this->imageTypesProvider->getProductImagesCodesList(); @@ -348,8 +523,6 @@ private function setImage(ProductInterface $product, array $data): void if ($productImage !== null) { $product->removeImage($productImage); } - - continue; } } @@ -365,20 +538,31 @@ private function setImage(ProductInterface $product, array $data): void } $productImage->setType($imageType); - $productImage->setPath($data[ImageTypesProvider::IMAGES_PREFIX . $imageType]); + $productImage->setPath( + $data[ImageTypesProvider::IMAGES_PREFIX . $imageType] + ); $product->addImage($productImage); } // create image if import has new one - foreach ($this->imageTypesProvider->extractImageTypeFromImport(\array_keys($data)) as $imageType) { - if (\in_array($imageType, $productImageCodes) || empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) { + foreach ( + $this->imageTypesProvider->extractImageTypeFromImport( + \array_keys($data) + ) as $imageType + ) { + if (\in_array( + $imageType, + $productImageCodes + ) || empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) { continue; } /** @var ProductImageInterface $productImage */ $productImage = $this->productImageFactory->createNew(); $productImage->setType($imageType); - $productImage->setPath($data[ImageTypesProvider::IMAGES_PREFIX . $imageType]); + $productImage->setPath( + $data[ImageTypesProvider::IMAGES_PREFIX . $imageType] + ); $product->addImage($productImage); } } diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index 7b49904e..fceaf157 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -333,7 +333,6 @@ services: - "@sylius.repository.product" - "@sylius.repository.taxon" - "@sylius.importer.metadata_validator" - - "@property_accessor" - "@sylius.repository.product_attribute" - "@sylius.service.attributes_code" - "@sylius.factory.product_attribute_value" @@ -344,11 +343,12 @@ services: - '@sylius.factory.channel_pricing' - '@sylius.repository.product_taxon' - '@sylius.repository.product_image' - - '@sylius.repository.product_variant' + - '@sylius.repository.product_option_value' - '@sylius.repository.channel_pricing' - '@sylius.service.product_image_code' - "@sylius.generator.slug" - "@sylius.importers_transformer_pool" + - "@sylius.repository.tax_category" - "@doctrine.orm.entity_manager" - ['Code', 'Locale', 'Name', 'Description', 'Short_description', 'Meta_description', 'Meta_keywords', 'Main_taxon', 'Taxons', 'Channels', 'Enabled', 'Price'] diff --git a/tests/Behat/Resources/fixtures/products_and_variants.csv b/tests/Behat/Resources/fixtures/products_and_variants.csv new file mode 100644 index 00000000..e3b1ddba --- /dev/null +++ b/tests/Behat/Resources/fixtures/products_and_variants.csv @@ -0,0 +1,6 @@ +Code,Parent_Code,Channels,Locale,Enabled,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Product_Option_color,Price,tax_category_code +1000,,shop,en_US,1,My Product Test Name 1000,,,,,taxon1,taxon1|subtaxon11,red,8400,normal +1001,1000,shop,en_US,1,,,,,,,,blue,18000,normal +1002,1000,shop,en_US,1,,,,,,,,green,37500,normal +1003,1000,shop,en_US,1,,,,,,,,yellow,84000,normal +1004,1000,shop,en_US,1,,,,,,,,violet,168000,normal