diff --git a/README.md b/README.md index 0680a497..3b6243ce 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,14 @@ fos_sylius_import_export: * payment_method (csv, excel, json) * tax_category (csv, excel, json) * customer (json) +* product (csv) ### Available exporter types * country (csv, excel, json) * order (csv, excel, json) * customer (csv, excel, json) +* product (csv) ## Example import files diff --git a/features/export/cli/exporting_products_to_csv_via_cli.feature b/features/export/cli/exporting_products_to_csv_via_cli.feature new file mode 100644 index 00000000..5c167fdf --- /dev/null +++ b/features/export/cli/exporting_products_to_csv_via_cli.feature @@ -0,0 +1,13 @@ +@managing_products +Feature: exporting products to csv-file + In order to have my products exported to an external target + As a developer + I want to be able to export product data to csv file from the commandline + + Background: + Given I have a working command-line interface + + @cli_importer_exporter + Scenario: Exporting products to csv-file + When I export "product" data as "csv" to the file "products_export.csv" with the cli-command + Then I should see "Exported" in the output diff --git a/features/export/ui/exporting_products.feature b/features/export/ui/exporting_products.feature new file mode 100644 index 00000000..3d3c23dd --- /dev/null +++ b/features/export/ui/exporting_products.feature @@ -0,0 +1,17 @@ +@managing_products +Feature: Export Products from grid + In order to have my products exported to an external target + As an Administrator + I want to be able to export product data to csv file from backOffice + + Background: + Given I am logged in as an administrator + And the store has a product "T-shirt cool" + + @ui + Scenario: Exporting products should export all of them + When I open the product admin index page + And I should see 1 products in the list + Then I go to "/admin/export/sylius.product/csv" homepage + And response should contain "Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled" + And response should contain 'T_SHIRT_COOL,en_US,"T-shirt cool",,,,,,,,1' diff --git a/features/export/ui/exporting_products_with_attributs.feature b/features/export/ui/exporting_products_with_attributs.feature new file mode 100644 index 00000000..8c8de5ef --- /dev/null +++ b/features/export/ui/exporting_products_with_attributs.feature @@ -0,0 +1,21 @@ +@managing_products +Feature: Export Products with attributes from grid + In order to have my products exported to an external target + As an Administrator + I want to be able to export product data and her attributes to csv file from backOffice + + Background: + Given I am logged in as an administrator + And the store has a select product attribute "Attribute select" with values "select1" and "select2" + And the store has a product "T-shirt cool" + And this product has text attribute "Attribute text" with value "Banana" + And this product has textarea attribute "Attribute textarea" with value "Banana
Bananaaaa !!!" + And this product has percent attribute "Attribute percent" with value 22% + + @ui + Scenario: Exporting products should export all of them + When I open the product admin index page + And I should see 1 products in the list + Then I go to "/admin/export/sylius.product/csv" homepage + And response should contain "Attribute_text,Attribute_textarea,Attribute_percent" + And response should contain 'Banana,"Banana
Bananaaaa !!!",0.22' diff --git a/features/import/cli/importing_products_from_csv_via_cli.feature b/features/import/cli/importing_products_from_csv_via_cli.feature new file mode 100644 index 00000000..33388e4d --- /dev/null +++ b/features/import/cli/importing_products_from_csv_via_cli.feature @@ -0,0 +1,16 @@ +@managing_products +Feature: Importing products from csv with the command-line interface + In order to have my products from external source + As a developer + I want to be able to import data from csv file from the commandline + + Background: + Given I have a working command-line interface + + @cli_importer_exporter + Scenario: Importing defined products with the cli-command + When I import "product" data from csv file "products.csv" file with the cli-command + Then I should see "Imported" in the output + And I should have at least the following product ids in the database: + | 123456 | + | 222333 | diff --git a/features/import/ui/importing_products.feature b/features/import/ui/importing_products.feature new file mode 100644 index 00000000..775a632f --- /dev/null +++ b/features/import/ui/importing_products.feature @@ -0,0 +1,17 @@ +@managing_products +Feature: Import Products from grid + In order to have my products exported to an external target + As an Administrator + I want to be able to import product data to csv file from backOffice + + Background: + Given I am logged in as an administrator + + @ui + Scenario: Import products should create all of them + When I open the product admin index page + And I import product data from "products.csv" csv file + Then I should see a notification that the import was successful + And I should see 2 products in the list + And the first product on the list should have name "Product 1" + And the last product on the list should have name "Product 2" diff --git a/features/import/ui/importing_products_update.feature b/features/import/ui/importing_products_update.feature new file mode 100644 index 00000000..db3a4d65 --- /dev/null +++ b/features/import/ui/importing_products_update.feature @@ -0,0 +1,19 @@ +@managing_products +Feature: Import Products from grid + In order to have my products exported to an external target + As an Administrator + I want to be able to import product data to csv file from backOffice + + Background: + Given I am logged in as an administrator + + @ui + Scenario: Import products should update them + When I open the product admin index page + And I import product data from "products.csv" csv file + Then I should see a notification that the import was successful + And I import product data from "products_update.csv" csv file + Then I should see a notification that the import was successful + And I should see 2 products in the list + And the first product on the list should have name "Product 1" + And the last product on the list should have name "Product 2 update" diff --git a/features/import/ui/importing_products_with_attributes.feature b/features/import/ui/importing_products_with_attributes.feature new file mode 100644 index 00000000..6b4a4dc2 --- /dev/null +++ b/features/import/ui/importing_products_with_attributes.feature @@ -0,0 +1,21 @@ +@managing_products +Feature: Import Products with attributes from grid + In order to have my products exported to an external target + As an Administrator + I want to be able to import product data and her attributes to csv file from backOffice + + Background: + Given I am logged in as an administrator + Given the store has locale "en_US" + And the store has a text product attribute "Attribute text" + And the store has a textarea product attribute "Attribute textarea" + And the store has a percent product attribute "Attribute percent" + + @ui + Scenario: Exporting products should export all of them + When I open the product admin index page + And I import product data from "products_attr.csv" csv file + Then I should see a notification that the import was successful + And I should see 2 products in the list + Then the product "Product 1" should appear in the registry + And attribute "Attribute text" of product "Product 1" should be "Banana" in "en_US" diff --git a/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php b/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php new file mode 100644 index 00000000..e02d2f4a --- /dev/null +++ b/spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php @@ -0,0 +1,53 @@ +shouldHaveType(ArrayToStringHandler::class); + } + + function it_extends() + { + $this->shouldHaveType(Handler::class); + } + + function it_should_implement() + { + $this->shouldImplement(HandlerInterface::class); + } + + function it_should_process_directly() + { + $array = ['a', 'b', 'c']; + $this->handle('test', $array)->shouldBeString(); + $this->handle('test', $array)->shouldBe('a|b|c'); + } + + function it_should_process_via_pool() + { + $array = ['a', 'b', 'c']; + + $generator = new RewindableGenerator(function () { + return [$this->getWrappedObject()]; + }, $count = 1); + + $pool = new Pool($generator); + + $result = $pool->handle('test', $array); + + Assert::same('a|b|c', $result); + } +} diff --git a/spec/Importer/ImporterResultSpec.php b/spec/Importer/ImporterResultSpec.php index d77ce520..eff968e6 100644 --- a/spec/Importer/ImporterResultSpec.php +++ b/spec/Importer/ImporterResultSpec.php @@ -6,14 +6,15 @@ use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResult; use PhpSpec\ObjectBehavior; +use Psr\Log\LoggerInterface; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\Stopwatch\StopwatchEvent; class ImporterResultSpec extends ObjectBehavior { - function let(Stopwatch $stopwatch) + function let(Stopwatch $stopwatch, LoggerInterface $logger) { - $this->beConstructedWith($stopwatch); + $this->beConstructedWith($stopwatch, $logger); } function it_is_initializable() diff --git a/spec/Importer/JsonResourceImporterSpec.php b/spec/Importer/JsonResourceImporterSpec.php index 14a85d31..5d826119 100644 --- a/spec/Importer/JsonResourceImporterSpec.php +++ b/spec/Importer/JsonResourceImporterSpec.php @@ -6,6 +6,7 @@ use Doctrine\Common\Persistence\ObjectManager; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResultInterface; +use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImportResultLoggerInterface; use FriendsOfSylius\SyliusImportExportPlugin\Importer\JsonResourceImporter; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ResourceImporter; use FriendsOfSylius\SyliusImportExportPlugin\Processor\ResourceProcessorInterface; @@ -17,7 +18,7 @@ class JsonResourceImporterSpec extends ObjectBehavior function let( ObjectManager $objectManager, ResourceProcessorInterface $resourceProcessor, - ImporterResultInterface $importerResult + ImportResultLoggerInterface $importerResult ) { $this->beConstructedWith($objectManager, $resourceProcessor, $importerResult, 0, false, false); } diff --git a/spec/Importer/ResourceImporterSpec.php b/spec/Importer/ResourceImporterSpec.php index 1e5d8675..0ebc28f4 100644 --- a/spec/Importer/ResourceImporterSpec.php +++ b/spec/Importer/ResourceImporterSpec.php @@ -7,6 +7,7 @@ use Doctrine\Common\Persistence\ObjectManager; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterInterface; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResultInterface; +use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImportResultLoggerInterface; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ResourceImporter; use FriendsOfSylius\SyliusImportExportPlugin\Processor\ResourceProcessorInterface; use PhpSpec\ObjectBehavior; @@ -21,7 +22,7 @@ function let( ReaderFactory $readerFactory, ObjectManager $objectManager, ResourceProcessorInterface $resourceProcessor, - ImporterResultInterface $importerResult + ImportResultLoggerInterface $importerResult ) { $this->beConstructedWith($readerFactory, $objectManager, $resourceProcessor, $importerResult, false, false, false); } diff --git a/src/Controller/ImportDataController.php b/src/Controller/ImportDataController.php index ca430090..f5922ded 100644 --- a/src/Controller/ImportDataController.php +++ b/src/Controller/ImportDataController.php @@ -8,6 +8,7 @@ use FriendsOfSylius\SyliusImportExportPlugin\Form\ImportType; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterInterface; use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterRegistry; +use FriendsOfSylius\SyliusImportExportPlugin\Importer\ImporterResult; use Sylius\Component\Registry\ServiceRegistryInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; @@ -84,14 +85,22 @@ private function importData(string $importer, FormInterface $form): void $this->flashBag->add('error', $message); } - /** @var UploadedFile $file */ + /** @var UploadedFile|null $file */ $file = $form->get('import-data')->getData(); /** @var ImporterInterface $service */ $service = $this->registry->get($name); + + if (null === $file) { + throw new ImporterException('No file selected'); + } + $path = $file->getRealPath(); + if (false === $path) { throw new ImporterException(sprintf('File %s could not be loaded', $file->getClientOriginalName())); } + + /** @var ImporterResult $result */ $result = $service->import($path); $message = sprintf( @@ -104,5 +113,9 @@ private function importData(string $importer, FormInterface $form): void ); $this->flashBag->add('success', $message); + + if ($result->getMessage() !== null) { + $this->flashBag->add('error', $result->getMessage()); + } } } diff --git a/src/Exporter/Plugin/PluginPool.php b/src/Exporter/Plugin/PluginPool.php index e6447b40..e5cb5f3f 100644 --- a/src/Exporter/Plugin/PluginPool.php +++ b/src/Exporter/Plugin/PluginPool.php @@ -6,17 +6,17 @@ class PluginPool implements PluginPoolInterface { - /** @var PluginInterface[] */ - private $plugins; - /** @var array */ - private $exportKeys; + protected $exportKeys; /** @var array */ - private $exportKeysNotFound; + protected $exportKeysAvailable = []; + + /** @var PluginInterface[] */ + private $plugins; /** @var array */ - private $exportKeysAvailable = []; + private $exportKeysNotFound; /** * @param PluginInterface[] $plugins diff --git a/src/Exporter/Plugin/ProductPluginPool.php b/src/Exporter/Plugin/ProductPluginPool.php new file mode 100644 index 00000000..b67212bc --- /dev/null +++ b/src/Exporter/Plugin/ProductPluginPool.php @@ -0,0 +1,35 @@ +attributeCodesProvider = $attributeCodesProvider; + $this->imageTypesProvider = $imageTypesProvider; + } + + public function initPlugins(array $ids): void + { + $this->exportKeys = \array_merge($this->exportKeys, $this->attributeCodesProvider->getAttributeCodesList()); + $this->exportKeys = \array_merge($this->exportKeys, $this->imageTypesProvider->getProductImagesCodesWithPrefixList()); + $this->exportKeysAvailable = $this->exportKeys; + parent::initPlugins($ids); + } +} diff --git a/src/Exporter/Plugin/ProductResourcePlugin.php b/src/Exporter/Plugin/ProductResourcePlugin.php new file mode 100644 index 00000000..1f23ecc9 --- /dev/null +++ b/src/Exporter/Plugin/ProductResourcePlugin.php @@ -0,0 +1,141 @@ +channelPricingRepository = $channelPricingRepository; + $this->productVariantRepository = $productVariantRepository; + } + + /** + * {@inheritdoc} + */ + public function init(array $idsToExport): void + { + parent::init($idsToExport); + + /** @var ProductInterface $resource */ + foreach ($this->resources as $resource) { + $this->addTranslationData($resource); + $this->addTaxonData($resource); + $this->addAttributeData($resource); + $this->addChannelData($resource); + $this->addImageData($resource); + $this->addPriceData($resource); + } + } + + private function addTranslationData(ProductInterface $resource): void + { + $translation = $resource->getTranslation(); + + $this->addDataForResource($resource, 'Locale', $translation->getLocale()); + $this->addDataForResource($resource, 'Name', $translation->getName()); + $this->addDataForResource($resource, 'Description', $translation->getDescription()); + $this->addDataForResource($resource, 'Short_description', $translation->getShortDescription()); + $this->addDataForResource($resource, 'Meta_description', $translation->getMetaDescription()); + $this->addDataForResource($resource, 'Meta_keywords', $translation->getMetaKeywords()); + } + + private function addTaxonData(ProductInterface $resource): void + { + $mainTaxonSlug = ''; + + /** @var \Sylius\Component\Core\Model\TaxonInterface $taxon */ + $mainTaxon = $resource->getMainTaxon(); + if (null !== $mainTaxon) { + $mainTaxonSlug = $mainTaxon->getCode(); + } + + $this->addDataForResource($resource, 'Main_taxon', $mainTaxonSlug); + + $taxonsSlug = ''; + $taxons = $resource->getTaxons(); + foreach ($taxons as $taxon) { + $taxonsSlug .= $taxon->getCode() . '|'; + } + + $taxonsSlug = \rtrim($taxonsSlug, '|'); + $this->addDataForResource($resource, 'Taxons', $taxonsSlug); + } + + private function addChannelData(ProductInterface $resource): void + { + $channelSlug = ''; + + /** @var \Sylius\Component\Core\Model\ChannelInterface[] $channel */ + $channels = $resource->getChannels(); + foreach ($channels as $channel) { + $channelSlug .= $channel->getCode() . '|'; + } + + $channelSlug = \rtrim($channelSlug, '|'); + + $this->addDataForResource($resource, 'Channels', $channelSlug); + } + + private function addAttributeData(ProductInterface $resource): void + { + $attributes = $resource->getAttributes(); + + /** @var AttributeValueInterface $attribute */ + foreach ($attributes as $attribute) { + $this->addDataForResource($resource, $attribute->getCode(), $attribute->getValue()); + } + } + + private function addImageData(ProductInterface $resource): void + { + $images = $resource->getImages(); + + /** @var ImageInterface $image */ + foreach ($images as $image) { + $this->addDataForResource($resource, ImageTypesProvider::IMAGES_PREFIX . $image->getType(), $image->getPath()); + } + } + + private function addPriceData(ProductInterface $resource): void + { + /** @var ProductVariantInterface|null $productVariant */ + $productVariant = $this->productVariantRepository->findOneBy(['code' => $resource->getCode()]); + if ($productVariant === null) { + return; + } + + /** @var \Sylius\Component\Core\Model\ChannelInterface[] $channel */ + $channels = $resource->getChannels(); + foreach ($channels as $channel) { + $channelPricing = $this->channelPricingRepository->findOneBy([ + 'channelCode' => $channel->getCode(), + 'productVariant' => $productVariant, + ]); + + $this->addDataForResource($resource, 'Price', $channelPricing->getPrice()); + } + } +} diff --git a/src/Exporter/ProductResourceExporter.php b/src/Exporter/ProductResourceExporter.php new file mode 100644 index 00000000..a795ad76 --- /dev/null +++ b/src/Exporter/ProductResourceExporter.php @@ -0,0 +1,39 @@ +attributeCodesProvider = $attributeCodesProvider; + $this->imageTypesProvider = $imageTypesProvider; + } + + public function export(array $idsToExport): void + { + $this->resourceKeys = \array_merge($this->resourceKeys, $this->attributeCodesProvider->getAttributeCodesList()); + $this->resourceKeys = \array_merge($this->resourceKeys, $this->imageTypesProvider->getProductImagesCodesWithPrefixList()); + parent::export($idsToExport); + } +} diff --git a/src/Exporter/Transformer/Handler/ArrayToStringHandler.php b/src/Exporter/Transformer/Handler/ArrayToStringHandler.php new file mode 100644 index 00000000..4a6fecd3 --- /dev/null +++ b/src/Exporter/Transformer/Handler/ArrayToStringHandler.php @@ -0,0 +1,23 @@ +stopwatch = $stopwatch; + $this->logger = $logger; } public function start(): void @@ -85,4 +95,19 @@ public function getDuration(): float { return $this->stopWatchEvent->getDuration(); } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } } diff --git a/src/Importer/JsonResourceImporter.php b/src/Importer/JsonResourceImporter.php index 317654e4..33fd5449 100644 --- a/src/Importer/JsonResourceImporter.php +++ b/src/Importer/JsonResourceImporter.php @@ -13,7 +13,7 @@ final class JsonResourceImporter extends ResourceImporter implements SingleDataA public function __construct( ObjectManager $objectManager, ResourceProcessorInterface $resourceProcessor, - ImporterResultInterface $importerResult, + ImportResultLoggerInterface $importerResult, int $batchSize, bool $failOnIncomplete, bool $stopOnFailure diff --git a/src/Importer/ResourceImporter.php b/src/Importer/ResourceImporter.php index 18910701..90e7818d 100644 --- a/src/Importer/ResourceImporter.php +++ b/src/Importer/ResourceImporter.php @@ -21,7 +21,7 @@ class ResourceImporter implements ImporterInterface /** @var ResourceProcessorInterface */ protected $resourceProcessor; - /** @var ImporterResultInterface */ + /** @var ImportResultLoggerInterface */ protected $result; /** @var int */ @@ -40,7 +40,7 @@ public function __construct( ReaderFactory $readerFactory, ObjectManager $objectManager, ResourceProcessorInterface $resourceProcessor, - ImporterResultInterface $importerResult, + ImportResultLoggerInterface $importerResult, int $batchSize, bool $failOnIncomplete, bool $stopOnFailure @@ -87,6 +87,8 @@ public function importData(int $i, array $row): bool $this->batchCount = 0; } } catch (ItemIncompleteException $e) { + $this->result->setMessage($e->getMessage()); + $this->result->getLogger()->critical($e->getMessage()); if ($this->failOnIncomplete) { $this->result->failed($i); if ($this->stopOnFailure) { @@ -97,6 +99,8 @@ public function importData(int $i, array $row): bool } } catch (ImporterException $e) { $this->result->failed($i); + $this->result->setMessage($e->getMessage()); + $this->result->getLogger()->critical($e->getMessage()); if ($this->stopOnFailure) { return true; } diff --git a/src/Importer/Transformer/Handler.php b/src/Importer/Transformer/Handler.php new file mode 100644 index 00000000..9ef95732 --- /dev/null +++ b/src/Importer/Transformer/Handler.php @@ -0,0 +1,55 @@ +successor === null) { + $this->successor = $handler; + + return; + } + + $this->successor->setSuccessor($handler); + } + + /** + * {@inheritdoc} + */ + final public function handle($type, $value) + { + $response = $this->allows($type, $value) ? $this->process($type, $value) : null; + + if (($response === null) && ($this->successor !== null)) { + $response = $this->successor->handle($type, $value); + } + + if ($response === null) { + return $value; + } + + return $response; + } + + /** + * Process the data. Return null to send to following handler. + * + * @return mixed|null + */ + abstract protected function process($type, $value); + + /** + * Will define whether this request will be handled by this handler (e.g. check on object type) + */ + abstract protected function allows($type, $value): bool; +} diff --git a/src/Importer/Transformer/Handler/StringToArrayHandler.php b/src/Importer/Transformer/Handler/StringToArrayHandler.php new file mode 100644 index 00000000..67649595 --- /dev/null +++ b/src/Importer/Transformer/Handler/StringToArrayHandler.php @@ -0,0 +1,23 @@ +format = $format; + } + + /** + * {@inheritdoc} + */ + protected function process($type, $value) + { + return \DateTime::createFromFormat($this->format, $value); + } + + protected function allows($type, $value): bool + { + return $type === 'datetime'; + } +} diff --git a/src/Importer/Transformer/Handler/StringToFloatHandler.php b/src/Importer/Transformer/Handler/StringToFloatHandler.php new file mode 100644 index 00000000..8f92811e --- /dev/null +++ b/src/Importer/Transformer/Handler/StringToFloatHandler.php @@ -0,0 +1,23 @@ +generator = $generator; + } + + /** + * {@inheritdoc} + */ + public function handle($type, $value) + { + if (null === $this->handler) { + $this->registerHandlers(); + } + + return $this->handler->handle($type, $value); + } + + private function registerHandlers(): void + { + foreach ($this->generator->getIterator() as $key => $handler) { + if ($key === 0) { + $this->handler = $handler; + + continue; + } + + $this->handler->setSuccessor($handler); + } + } +} diff --git a/src/Importer/Transformer/TransformerPoolInterface.php b/src/Importer/Transformer/TransformerPoolInterface.php new file mode 100644 index 00000000..eded9ebf --- /dev/null +++ b/src/Importer/Transformer/TransformerPoolInterface.php @@ -0,0 +1,13 @@ +resourceProductFactory = $productFactory; + $this->resourceTaxonFactory = $taxonFactory; + $this->productRepository = $productRepository; + $this->taxonRepository = $taxonRepository; + $this->metadataValidator = $metadataValidator; + $this->propertyAccessor = $propertyAccessor; + $this->productAttributeRepository = $productAttributeRepository; + $this->productAttributeValueFactory = $productAttributeValueFactory; + $this->attributeCodesProvider = $attributeCodesProvider; + $this->headerKeys = $headerKeys; + $this->slugGenerator = $slugGenerator; + $this->transformerPool = $transformerPool; + $this->manager = $manager; + $this->channelRepository = $channelRepository; + $this->productTaxonFactory = $productTaxonFactory; + $this->productTaxonRepository = $productTaxonRepository; + $this->productImageFactory = $productImageFactory; + $this->productImageRepository = $productImageRepository; + $this->imageTypesProvider = $imageTypesProvider; + $this->productVariantFactory = $productVariantFactory; + $this->productVariantRepository = $productVariantRepository; + $this->channelPricingFactory = $channelPricingFactory; + $this->channelPricingRepository = $channelPricingRepository; + } + + /** + * {@inheritdoc} + */ + public function process(array $data): void + { + $this->attrCode = $this->attributeCodesProvider->getAttributeCodesList(); + $this->imageCode = $this->imageTypesProvider->getProductImagesCodesWithPrefixList(); + + $this->headerKeys = \array_merge($this->headerKeys, $this->attrCode); + $this->headerKeys = \array_merge($this->headerKeys, $this->imageCode); + $this->metadataValidator->validateHeaders($this->headerKeys, $data); + + $product = $this->getProduct($data['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); + + $this->productRepository->add($product); + } + + private function getProduct(string $code): ProductInterface + { + /** @var ProductInterface|null $product */ + $product = $this->productRepository->findOneBy(['code' => $code]); + if (null === $product) { + /** @var ProductInterface $product */ + $product = $this->resourceProductFactory->createNew(); + $product->setCode($code); + } + + return $product; + } + + private function getProductVariant(string $code): ProductVariantInterface + { + /** @var ProductVariantInterface|null $productVariant */ + $productVariant = $this->productVariantRepository->findOneBy(['code' => $code]); + if ($productVariant === null) { + /** @var ProductVariantInterface $productVariant */ + $productVariant = $this->productVariantFactory->createNew(); + $productVariant->setCode($code); + } + + 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; + } + + /** @var ProductInterface $product */ + $product->setMainTaxon($taxon); + + $this->addTaxonToProduct($product, $data['Main_taxon']); + } + + 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 setAttributesData(ProductInterface $product, array $data): void + { + foreach ($this->attrCode as $attrCode) { + $attributeValue = $product->getAttributeByCodeAndLocale($attrCode); + + if (empty($data[$attrCode])) { + if ($attributeValue !== null) { + $product->removeAttribute($attributeValue); + } + + continue; + } + + if ($attributeValue !== null) { + if (null !== $this->transformerPool) { + $data[$attrCode] = $this->transformerPool->handle( + $attributeValue->getType(), + $data[$attrCode] + ); + } + + $attributeValue->setValue($data[$attrCode]); + + continue; + } + + $this->setAttributeValue($product, $data, $attrCode); + } + } + + 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->setCurrentLocale($data['Locale']); + $productVariant->setName(substr($data['Name'], 0, 255)); + + $channels = \explode('|', $data['Channels']); + foreach ($channels as $channelCode) { + $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 + { + /** @var ProductAttribute $productAttr */ + $productAttr = $this->productAttributeRepository->findOneBy(['code' => $attrCode]); + /** @var ProductAttributeValueInterface $attr */ + $attr = $this->productAttributeValueFactory->createNew(); + $attr->setAttribute($productAttr); + $attr->setProduct($product); + $attr->setLocaleCode($product->getTranslation()->getLocale()); + + if (null !== $this->transformerPool) { + $data[$attrCode] = $this->transformerPool->handle($productAttr->getType(), $data[$attrCode]); + } + + $attr->setValue($data[$attrCode]); + $product->addAttribute($attr); + $this->manager->persist($attr); + } + + private function setChannel(ProductInterface $product, array $data): void + { + $channels = \explode('|', $data['Channels']); + foreach ($channels as $channelCode) { + $channel = $this->channelRepository->findOneBy(['code' => $channelCode]); + if ($channel === null) { + continue; + } + $product->addChannel($channel); + } + } + + private function addTaxonToProduct(ProductInterface $product, string $taxonCode): void + { + /** @var Taxon|null $taxon */ + $taxon = $this->taxonRepository->findOneBy(['code' => $taxonCode]); + if ($taxon === null) { + return; + } + + $productTaxon = $this->productTaxonRepository->findOneByProductCodeAndTaxonCode( + $product->getCode(), + $taxon->getCode() + ); + + if (null !== $productTaxon) { + return; + } + + /** @var ProductTaxonInterface $productTaxon */ + $productTaxon = $this->productTaxonFactory->createNew(); + $productTaxon->setTaxon($taxon); + $product->addProductTaxon($productTaxon); + } + + private function setImage(ProductInterface $product, array $data): void + { + $productImageCodes = $this->imageTypesProvider->getProductImagesCodesList(); + foreach ($productImageCodes as $imageType) { + /** @var ProductImageInterface $productImage */ + $productImageByType = $product->getImagesByType($imageType); + + // remove old images if import is empty + foreach ($productImageByType as $productImage) { + if (empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) { + if ($productImage !== null) { + $product->removeImage($productImage); + } + + continue; + } + } + + if (empty($data[ImageTypesProvider::IMAGES_PREFIX . $imageType])) { + continue; + } + + if (count($productImageByType) === 0) { + /** @var ProductImageInterface $productImage */ + $productImage = $this->productImageFactory->createNew(); + } else { + $productImage = $productImageByType->first(); + } + + $productImage->setType($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])) { + continue; + } + + /** @var ProductImageInterface $productImage */ + $productImage = $this->productImageFactory->createNew(); + $productImage->setType($imageType); + $productImage->setPath($data[ImageTypesProvider::IMAGES_PREFIX . $imageType]); + $product->addImage($productImage); + } + } +} diff --git a/src/Repository/ProductImageImageRepository.php b/src/Repository/ProductImageImageRepository.php new file mode 100644 index 00000000..858b06e2 --- /dev/null +++ b/src/Repository/ProductImageImageRepository.php @@ -0,0 +1,19 @@ +createQueryBuilder('p') + ->groupBy('p.type') + ->addGroupBy('p.id') + ->getQuery() + ->getArrayResult(); + } +} diff --git a/src/Repository/ProductImageRepositoryInterface.php b/src/Repository/ProductImageRepositoryInterface.php new file mode 100644 index 00000000..e3590459 --- /dev/null +++ b/src/Repository/ProductImageRepositoryInterface.php @@ -0,0 +1,12 @@ +productAttributeRepository = $productAttributeRepository; + } + + public function getAttributeCodesList(): array + { + $attrSlug = []; + $productAttr = $this->productAttributeRepository->findBy([], ['id' => 'ASC']); + /** @var ProductAttribute $attr */ + foreach ($productAttr as $attr) { + if (!empty($attr->getCode())) { + $attrSlug[] = $attr->getCode(); + } + } + + return $attrSlug; + } +} diff --git a/src/Service/AttributeCodesProviderInterface.php b/src/Service/AttributeCodesProviderInterface.php new file mode 100644 index 00000000..38415d07 --- /dev/null +++ b/src/Service/AttributeCodesProviderInterface.php @@ -0,0 +1,13 @@ +productImageRepository = $productImageRepository; + } + + public function getProductImagesCodesList(): array + { + return $this->extractProductImagesType(); + } + + public function getProductImagesCodesWithPrefixList(): array + { + return $this->extractProductImagesType(self::IMAGES_PREFIX); + } + + public function extractImageTypeFromImport(array $keys): array + { + $keys = \array_filter($keys, function ($value) { + return \mb_substr($value, 0, \mb_strlen(self::IMAGES_PREFIX)) === self::IMAGES_PREFIX; + }); + + $keys = \array_map([self::class, 'extractTypeOfImage'], $keys); + + return $keys; + } + + private function extractProductImagesType(string $prefix = ''): array + { + $attrSlug = []; + $productImages = $this->productImageRepository->findTypes(); + foreach ($productImages as $attr) { + if (empty($attr['type'])) { + continue; + } + + $attrSlug[] = $prefix . $attr['type']; + } + + $attrSlug = \array_unique($attrSlug); + + return $attrSlug; + } + + private function extractTypeOfImage(string $value): string + { + return \mb_substr($value, \mb_strlen(self::IMAGES_PREFIX)); + } +} diff --git a/src/Service/ImageTypesProviderInterface.php b/src/Service/ImageTypesProviderInterface.php new file mode 100644 index 00000000..8faabd03 --- /dev/null +++ b/src/Service/ImageTypesProviderInterface.php @@ -0,0 +1,14 @@ +repository->findBy(['code' => $productId]); + Assert::assertNotNull($product); + } + } +} diff --git a/tests/Behat/Context/ProductsContext.php b/tests/Behat/Context/ProductsContext.php new file mode 100644 index 00000000..d09cb417 --- /dev/null +++ b/tests/Behat/Context/ProductsContext.php @@ -0,0 +1,158 @@ +productIndexPage = $productIndexPage; + $this->productContext = $productContext; + + parent::__construct($session, $parameters, $router); + } + + /** + * {@inheritdoc} + */ + public function getRouteName(): string + { + return 'sylius_admin_product_index'; + } + + /** + * @When I import product data from :file :format file + */ + public function iImportProductDataFromCsvFile(string $file, string $format): void + { + $this->productIndexPage->importData($file, $format); + } + + /** + * @When I open the product admin index page + */ + public function iOpenTheProductIndexPage(): void + { + $this->productIndexPage->open(); + } + + /** + * @Then I should see an export button + */ + public function iShouldSeeExportButton(): void + { + Assert::assertEquals( + 'Export', + $this->getElement('export_button_text')->getText() + ); + } + + /** + * @Then I click on :element + */ + public function iClickOn(string $element): void + { + $page = $this->getSession()->getPage(); + $findName = $page->find('css', $element); + if (!$findName) { + throw new ElementNotFoundException($element . ' could not be found'); + } + $findName->click(); + } + + /** + * @Then I should see a link to export products to CSV + */ + public function iShouldSeeExportCSVLink(): void + { + Assert::assertContains( + 'CSV', + $this->getElement('export_links')->find('css', 'a.item')->getText() + ); + } + + /** + * @Then the product :product should appear in the registry + */ + public function theProductShouldAppearInTheRegistry(string $productname): void + { + $product = $this->productContext->getProductByName($productname); + + Assert::assertNotNull( + $product, + sprintf('Product with name "%s" does not exist', $productname) + ); + } + + /** + * @Then :amount products should be in the registry + */ + public function amountProductsShouldBeInTheRegistry(int $amount): void + { + $productCount = $this->productContext->getProductCount(); + + Assert::assertEquals( + $amount, + $productCount, + 'expected value differs from actual value ' . $amount . ' !== ' . $productCount + ); + } + + /** + * @When I go to :hp homepage + */ + public function goToSpecificHomepage(string $hp): void + { + $this->getSession(null)->visit($hp); + } + + /** + * Checks that response body contains specific text. + * + * @Then response should contain :text + */ + public function theResponseShouldContain(string $text): void + { + $responseText = $this->getSession()->getPage()->getContent(); + + if (strpos($responseText, $text) !== false) { + return; + } + + throw new ResponseTextException(sprintf("Response '%s' does not contain: '%s'", $responseText, $text), $this->getDriver()); + } + + /** + * {@inheritdoc} + */ + protected function getDefinedElements(): array + { + return array_merge(parent::getDefinedElements(), [ + 'export_button_text' => '.buttons div.dropdown span.text', + 'export_links' => '.buttons div.dropdown div.menu', + ]); + } +} diff --git a/tests/Behat/Resources/fixtures/products.csv b/tests/Behat/Resources/fixtures/products.csv new file mode 100644 index 00000000..67623e67 --- /dev/null +++ b/tests/Behat/Resources/fixtures/products.csv @@ -0,0 +1,3 @@ +Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price +123456,en_US,Product 1,Description 1,Short description 1,Meta description 1,Meta keywords 1,,,,1,22 +222333,en_US,Product 2,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,33 diff --git a/tests/Behat/Resources/fixtures/products_attr.csv b/tests/Behat/Resources/fixtures/products_attr.csv new file mode 100644 index 00000000..6d503521 --- /dev/null +++ b/tests/Behat/Resources/fixtures/products_attr.csv @@ -0,0 +1,3 @@ +Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price,Attribute_text,Attribute_textarea,Attribute_percent +123456,en_US,Product 1,Description 1,Short description 1,Meta description 1,Meta keywords 1,,,,1,25,Banana,Attribute textarea
two,0.22 +222333,en_US,Product 2,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,26,,, diff --git a/tests/Behat/Resources/fixtures/products_update.csv b/tests/Behat/Resources/fixtures/products_update.csv new file mode 100644 index 00000000..5c93c4f0 --- /dev/null +++ b/tests/Behat/Resources/fixtures/products_update.csv @@ -0,0 +1,2 @@ +Code,Locale,Name,Description,Short_description,Meta_description,Meta_keywords,Main_taxon,Taxons,Channels,Enabled,Price +222333,en_US,Product 2 update,Description 2,Short description 2,Meta description 2,Meta keywords 2,,,,1,42 diff --git a/tests/Behat/Resources/services.xml b/tests/Behat/Resources/services.xml index 1dd51cc9..4f27134b 100644 --- a/tests/Behat/Resources/services.xml +++ b/tests/Behat/Resources/services.xml @@ -28,6 +28,13 @@ + + + + + + + @@ -77,6 +84,14 @@ %behat.files_directory% + + + + + + + %behat.files_directory% + sylius_admin_country_index @@ -94,5 +109,9 @@ sylius_admin_payment_method_index %behat.files_directory% + + sylius_admin_product_index + %behat.files_directory% + diff --git a/tests/Behat/Resources/suites.yml b/tests/Behat/Resources/suites.yml index f3bd5156..5272122e 100644 --- a/tests/Behat/Resources/suites.yml +++ b/tests/Behat/Resources/suites.yml @@ -80,6 +80,28 @@ default: filters: tags: "@managing_customer_groups && @ui" + ui_managing_products: + contexts: + - sylius.behat.context.hook.doctrine_orm + + - sylius.behat.context.transform.product + - sylius.behat.context.transform.shared_storage + + - sylius.behat.context.setup.geographical + - sylius.behat.context.setup.admin_security + + - sylius.behat.context.ui.admin.managing_products + - sylius.behat.context.ui.admin.notification + + - test.behat.context.notification_context + - test.behat.context.products_context + + - sylius.behat.context.setup.locale + - sylius.behat.context.setup.product + - sylius.behat.context.setup.product_attribute + filters: + tags: "@managing_products && @ui" + cli_managing_countries: contexts: - sylius.behat.context.hook.doctrine_orm @@ -131,6 +153,14 @@ default: filters: tags: "@managing_customers && @cli_importer_exporter" + cli_managing_products: + contexts: + - sylius.behat.context.hook.doctrine_orm + - test.behat.context.cli_products_context + - sylius.behat.context.cli.installer + filters: + tags: "@managing_products && @cli_importer_exporter" + extensions: FriendsOfBehat\SuiteSettingsExtension: paths: diff --git a/web/media/image/.gitkeep b/web/media/image/.gitkeep new file mode 100644 index 00000000..e69de29b