From 0557db82a609c72357de22aebc1210fd0043a10f Mon Sep 17 00:00:00 2001 From: oallain Date: Fri, 26 Jul 2019 09:11:38 +0200 Subject: [PATCH] Feature/import export product in csv (#196) * add import/export of products * use context in behat * fix returns type * enable products import/export behat tests use (new) comma separator add cli behat tests update README.md fix return type and typo fix exportKeysAvailable clean not need store data importer add transformer improve product import/export update for new controller style allow to remove product attribute value update for sylius 1.4 * add images channels locals add images channels locals * upgrade feature for tests imports fix phpstan version * set length to product datas * add variant price * upgrade to clean code * update channel price * fix export images * fix export images * fix export images * fix export price * use ImportResultLoggerInterface * fix ImporterResultInterface --- README.md | 2 + .../exporting_products_to_csv_via_cli.feature | 13 + features/export/ui/exporting_products.feature | 17 + .../exporting_products_with_attributs.feature | 21 + ...mporting_products_from_csv_via_cli.feature | 16 + features/import/ui/importing_products.feature | 17 + .../ui/importing_products_update.feature | 19 + ...importing_products_with_attributes.feature | 21 + .../Handler/ArrayToStringHandlerSpec.php | 53 +++ spec/Importer/ImporterResultSpec.php | 5 +- spec/Importer/JsonResourceImporterSpec.php | 3 +- spec/Importer/ResourceImporterSpec.php | 3 +- src/Controller/ImportDataController.php | 15 +- src/Exporter/Plugin/PluginPool.php | 12 +- src/Exporter/Plugin/ProductPluginPool.php | 35 ++ src/Exporter/Plugin/ProductResourcePlugin.php | 141 +++++++ src/Exporter/ProductResourceExporter.php | 39 ++ .../Handler/ArrayToStringHandler.php | 23 ++ .../ImportResultLoggerAwareInterface.php | 16 + src/Importer/ImportResultLoggerInterface.php | 9 + src/Importer/ImporterResult.php | 31 +- src/Importer/JsonResourceImporter.php | 2 +- src/Importer/ResourceImporter.php | 8 +- src/Importer/Transformer/Handler.php | 55 +++ .../Handler/StringToArrayHandler.php | 23 ++ .../Handler/StringToBooleanHandler.php | 23 ++ .../Handler/StringToDateTimeHandler.php | 31 ++ .../Handler/StringToFloatHandler.php | 23 ++ .../Handler/StringToIntegerHandler.php | 23 ++ src/Importer/Transformer/HandlerInterface.php | 22 + src/Importer/Transformer/Pool.php | 44 ++ .../Transformer/TransformerPoolInterface.php | 13 + src/Processor/ProductProcessor.php | 383 ++++++++++++++++++ .../ProductImageImageRepository.php | 19 + .../ProductImageRepositoryInterface.php | 12 + src/Resources/config/routing.yml | 10 + src/Resources/config/services.yml | 116 +++++- src/Resources/config/services_export_csv.yml | 12 + src/Resources/config/services_import_csv.yml | 13 + src/Service/AttributeCodesProvider.php | 33 ++ .../AttributeCodesProviderInterface.php | 13 + src/Service/ImageTypesProvider.php | 63 +++ src/Service/ImageTypesProviderInterface.php | 14 + tests/Behat/Context/CliProductsContext.php | 22 + tests/Behat/Context/ProductsContext.php | 158 ++++++++ tests/Behat/Resources/fixtures/products.csv | 3 + .../Resources/fixtures/products_attr.csv | 3 + .../Resources/fixtures/products_update.csv | 2 + tests/Behat/Resources/services.xml | 19 + tests/Behat/Resources/suites.yml | 30 ++ web/media/image/.gitkeep | 0 51 files changed, 1684 insertions(+), 19 deletions(-) create mode 100644 features/export/cli/exporting_products_to_csv_via_cli.feature create mode 100644 features/export/ui/exporting_products.feature create mode 100644 features/export/ui/exporting_products_with_attributs.feature create mode 100644 features/import/cli/importing_products_from_csv_via_cli.feature create mode 100644 features/import/ui/importing_products.feature create mode 100644 features/import/ui/importing_products_update.feature create mode 100644 features/import/ui/importing_products_with_attributes.feature create mode 100644 spec/Exporter/Transformer/Handler/ArrayToStringHandlerSpec.php create mode 100644 src/Exporter/Plugin/ProductPluginPool.php create mode 100644 src/Exporter/Plugin/ProductResourcePlugin.php create mode 100644 src/Exporter/ProductResourceExporter.php create mode 100644 src/Exporter/Transformer/Handler/ArrayToStringHandler.php create mode 100644 src/Importer/ImportResultLoggerAwareInterface.php create mode 100644 src/Importer/ImportResultLoggerInterface.php create mode 100644 src/Importer/Transformer/Handler.php create mode 100644 src/Importer/Transformer/Handler/StringToArrayHandler.php create mode 100644 src/Importer/Transformer/Handler/StringToBooleanHandler.php create mode 100644 src/Importer/Transformer/Handler/StringToDateTimeHandler.php create mode 100644 src/Importer/Transformer/Handler/StringToFloatHandler.php create mode 100644 src/Importer/Transformer/Handler/StringToIntegerHandler.php create mode 100644 src/Importer/Transformer/HandlerInterface.php create mode 100644 src/Importer/Transformer/Pool.php create mode 100644 src/Importer/Transformer/TransformerPoolInterface.php create mode 100644 src/Processor/ProductProcessor.php create mode 100644 src/Repository/ProductImageImageRepository.php create mode 100644 src/Repository/ProductImageRepositoryInterface.php create mode 100644 src/Service/AttributeCodesProvider.php create mode 100644 src/Service/AttributeCodesProviderInterface.php create mode 100644 src/Service/ImageTypesProvider.php create mode 100644 src/Service/ImageTypesProviderInterface.php create mode 100644 tests/Behat/Context/CliProductsContext.php create mode 100644 tests/Behat/Context/ProductsContext.php create mode 100644 tests/Behat/Resources/fixtures/products.csv create mode 100644 tests/Behat/Resources/fixtures/products_attr.csv create mode 100644 tests/Behat/Resources/fixtures/products_update.csv create mode 100644 web/media/image/.gitkeep 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