Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import Product Models #191

Merged
merged 9 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,12 @@
<tag name="kernel.event_listener" event="sylius.menu.admin.main" method="addAdminMenuItems" />
</service>

<service id="webgriffe_sylius_akeneo.product_model.importer" class="Webgriffe\SyliusAkeneoPlugin\ProductModel\Importer">
<argument type="service" id="webgriffe_sylius_akeneo.api_client" />
<argument type="service" id="event_dispatcher" />
<argument type="service" id="webgriffe_sylius_akeneo.command_bus" />
<tag name="webgriffe_sylius_akeneo.importer" />
</service>

</services>
</container>
7 changes: 7 additions & 0 deletions docs/architecture_and_customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ app.my_custom_value_handler:
- { name: 'webgriffe_sylius_akeneo.product.value_handler', priority: 42 }
```

### Product models importer

Another provided importer is the **product models
importer** (`Webgriffe\SyliusAkeneoPlugin\ProductModel\Importer`). This importer imports the Akeneo product models
to the corresponding Sylius products and product variants. Basically, it dispatch an `ItemImport` message for each
product, on Akeneo, belonging to the product model. So it uses the same logic of the product importer described above.

### Product associations importer

Another provided importer is the **product associations
Expand Down
10 changes: 10 additions & 0 deletions docs/upgrade/upgrade-2.*.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ nav_order: 0
parent: Upgrade
---

# Upgrade from `v2.3.0` to `v2.4.0`

The v2.4.0 version introduces the Product Model importer. If you are using the webhook no changes are requested as it will be automatically enqueued on every update.
If you are using the cronjob, you have to add the `--importer="ProductModel"` option to the command that imports every minute:

```git
- * * * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:import --since-file=/path/to/sylius/var/storage/akeneo-import-sincefile.txt --importer="Product" --importer="ProductAssociations"
+ * * * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:import --since-file=/path/to/sylius/var/storage/akeneo-import-sincefile.txt --importer="Product" --importer="ProductModel" --importer="ProductAssociations"
```

# Upgrade from `v2.2.0` to `v2.3.0`

The v2.3.0 version introduces the support for webhooks. To enable check the new documentation [here](../webhook.html).
Expand Down
6 changes: 3 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,18 +460,18 @@ To make all importers and other plugin features work automatically the following

```
0 * * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:import --all --importer="AttributeOptions"
* * * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:import --since-file=/path/to/sylius/var/storage/akeneo-import-sincefile.txt --importer="Product" --importer="ProductAssociations"
* * * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:import --since-file=/path/to/sylius/var/storage/akeneo-import-sincefile.txt --importer="Product" --importer="ProductModel" --importer="ProductAssociations"
0 */6 * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:reconcile
0 0 * * * /path/to/sylius/bin/console -e prod -q webgriffe:akeneo:cleanup-item-import-results
```

This will:

* Import the update of all attribute options every hour
* Import, every minute, all products that have been modified since the last execution, along with their associations
* Import, every minute, all products and product models that have been modified since the last execution, along with their associations
* Reconcile Akeneo deleted products every 6 hours

> *NB*: The line that imports products and product associations every minute should be added only if you do not use the
> *NB*: The line that imports products, product models and product associations every minute should be added only if you do not use the
> webhook feature (see next chapter). Otherwise, the products will be imported twice.

Import and Reconcile commands uses a [lock mechanism](https://symfony.com/doc/current/console/lockable_trait.html) which
Expand Down
32 changes: 32 additions & 0 deletions features/importing_product_models.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@importing_product_models
Feature: Importing product models
In order to show updated data about my products
As a Store Owner
I want to import product models from Akeneo PIM

Background:
Given the store operates on a single channel

@cli
Scenario: Importing product model and its variants
Given there is an attribute "size" on Akeneo of type "pim_catalog_simpleselect"

And there is a family variant "accessories_size" on Akeneo for the family "accessories"
And the family variant "accessories_size" of family "accessories" has the attribute "size" as axes of first level

And there is a product model "MODEL_BRAIDED_HAT" on Akeneo of family "accessories" having variant "accessories_size"

And there is a product "BRAIDED_HAT_M" on Akeneo
And the product "BRAIDED_HAT_M" has parent "MODEL_BRAIDED_HAT"
And the product "BRAIDED_HAT_M" has a price attribute with amount "33.99" and currency "USD"

And there is a product "BRAIDED_HAT_L" on Akeneo
And the product "BRAIDED_HAT_L" has parent "MODEL_BRAIDED_HAT"
And the product "BRAIDED_HAT_L" has a price attribute with amount "33.00" and currency "USD"

And the store is also available in "it_IT"

When I import all "ProductModels" from Akeneo
Then the product "MODEL_BRAIDED_HAT" should exist
And the product variant "BRAIDED_HAT_M" of product "MODEL_BRAIDED_HAT" should exist
And the product variant "BRAIDED_HAT_L" of product "MODEL_BRAIDED_HAT" should exist
4 changes: 1 addition & 3 deletions features/importing_products.feature
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ Feature: Importing products
And I am logged in as an administrator
When I browse products
And I schedule an Akeneo PIM import for the "Model Braided Hat" product
Then I should be notified that "BRAIDED_HAT_S" has been successfully enqueued
And I should be notified that "BRAIDED_HAT_M" has been successfully enqueued
And I should be notified that "BRAIDED_HAT_L" has been successfully enqueued
Then I should be notified that "MODEL_BRAIDED_HAT" has been successfully enqueued
And the product "MODEL_BRAIDED_HAT" should exist
And the product variant "BRAIDED_HAT_S" of product "MODEL_BRAIDED_HAT" should exist
And the product variant "BRAIDED_HAT_M" of product "MODEL_BRAIDED_HAT" should exist
Expand Down
43 changes: 27 additions & 16 deletions src/Controller/ProductImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Webgriffe\SyliusAkeneoPlugin\Controller;

use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Product\Model\ProductInterface;
use Sylius\Component\Product\Repository\ProductRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand All @@ -12,6 +13,9 @@
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Webgriffe\SyliusAkeneoPlugin\Message\ItemImport;
use Webgriffe\SyliusAkeneoPlugin\Product\Importer as ProductImporter;
use Webgriffe\SyliusAkeneoPlugin\ProductAssociations\Importer as ProductAssociationsImporter;
use Webgriffe\SyliusAkeneoPlugin\ProductModel\Importer as ProductModelImporter;
use Webmozart\Assert\Assert;

final class ProductImportController extends AbstractController
Expand All @@ -30,28 +34,35 @@ public function importAction(int $productId): Response
if ($product === null) {
throw new NotFoundHttpException('Product not found');
}

$enqueued = [];
foreach ($product->getVariants() as $productVariant) {
$productCode = $product->getCode();
Assert::string($productCode);
$this->messageBus->dispatch(new ItemImport(
ProductModelImporter::AKENEO_ENTITY,
$productCode,
));
$this->addFlash(
'success',
$this->translator->trans('webgriffe_sylius_akeneo.ui.enqueued_success', ['{code}' => $productCode]),
);
if ($product->isSimple()) {
$productVariant = $product->getVariants()->first();
Assert::isInstanceOf($productVariant, ProductVariantInterface::class);
$productVariantCode = $productVariant->getCode();
Assert::notNull($productVariantCode);

$queueItem = new ItemImport(
'Product',
Assert::string($productVariantCode);
$this->messageBus->dispatch(new ItemImport(
ProductImporter::AKENEO_ENTITY,
$productVariantCode,
);
$this->messageBus->dispatch($queueItem);

$enqueued[] = $productVariantCode;
}

foreach ($enqueued as $code) {
));
$this->messageBus->dispatch(new ItemImport(
ProductAssociationsImporter::AKENEO_ENTITY,
$productVariantCode,
));
$this->addFlash(
'success',
$this->translator->trans('webgriffe_sylius_akeneo.ui.enqueued_success', ['{code}' => $code]),
$this->translator->trans('webgriffe_sylius_akeneo.ui.enqueued_success', ['{code}' => $productVariantCode]),
);
}

return $this->redirectToRoute('sylius_admin_product_index');
return $this->redirectToRoute('webgriffe_sylius_akeneo_admin_item_import_result_index');
}
}
12 changes: 12 additions & 0 deletions src/Controller/WebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Webgriffe\SyliusAkeneoPlugin\Message\ItemImport;
use Webgriffe\SyliusAkeneoPlugin\Product\Importer as ProductImporter;
use Webgriffe\SyliusAkeneoPlugin\ProductAssociations\Importer as ProductAssociationsImporter;
use Webgriffe\SyliusAkeneoPlugin\ProductModel\Importer as ProductModelImporter;

/**
* @psalm-type AkeneoEventProduct = array{
Expand Down Expand Up @@ -136,6 +137,17 @@ public function postAction(Request $request): Response
$productCode,
));
}
if (array_key_exists('code', $resource)) {
$productModelCode = $resource['code'];
$this->logger->debug(sprintf(
'Dispatching product model import message for %s',
$productModelCode,
));
$this->messageBus->dispatch(new ItemImport(
ProductModelImporter::AKENEO_ENTITY,
$productModelCode,
));
}
}

return new Response();
Expand Down
33 changes: 33 additions & 0 deletions src/Event/ProductWithParentSearchBuilderBuiltEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Webgriffe\SyliusAkeneoPlugin\Event;

use Akeneo\Pim\ApiClient\Search\SearchBuilder;
use Webgriffe\SyliusAkeneoPlugin\ImporterInterface;

final class ProductWithParentSearchBuilderBuiltEvent
{
public function __construct(
private ImporterInterface $importer,
private SearchBuilder $searchBuilder,
private string $productModelCode,
) {
}

public function getImporter(): ImporterInterface
{
return $this->importer;
}

public function getSearchBuilder(): SearchBuilder
{
return $this->searchBuilder;
}

public function getProductModelCode(): string
{
return $this->productModelCode;
}
}
90 changes: 90 additions & 0 deletions src/ProductModel/Importer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Webgriffe\SyliusAkeneoPlugin\ProductModel;

use Akeneo\Pim\ApiClient\AkeneoPimClientInterface;
use Akeneo\Pim\ApiClient\Search\SearchBuilder;
use DateTime;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Webgriffe\SyliusAkeneoPlugin\Event\IdentifiersModifiedSinceSearchBuilderBuiltEvent;
use Webgriffe\SyliusAkeneoPlugin\Event\ProductWithParentSearchBuilderBuiltEvent;
use Webgriffe\SyliusAkeneoPlugin\ImporterInterface;
use Webgriffe\SyliusAkeneoPlugin\Message\ItemImport;
use Webgriffe\SyliusAkeneoPlugin\Product\Importer as ProductImporter;
use Webgriffe\SyliusAkeneoPlugin\ProductAssociations\Importer as ProductAssociationsImporter;
use Webmozart\Assert\Assert;

/**
* @psalm-type AkeneoProductModel = array{
* code: string,
* family: string,
* family_variant: string,
* parent: ?string,
* }
* @psalm-type AkeneoProduct = array{
* identifier: string,
* enabled: bool,
* family: ?string,
* parent: ?string,
* }
*/
final class Importer implements ImporterInterface
{
public const AKENEO_ENTITY = 'ProductModel';

public function __construct(
private AkeneoPimClientInterface $akeneoPimClient,
private EventDispatcherInterface $eventDispatcher,
private MessageBusInterface $messageBus,
) {
}

public function getAkeneoEntity(): string
{
return self::AKENEO_ENTITY;
}

public function getIdentifiersModifiedSince(DateTime $sinceDate): array
{
$searchBuilder = new SearchBuilder();
$searchBuilder->addFilter('updated', '>', $sinceDate->format('Y-m-d H:i:s'));
$this->eventDispatcher->dispatch(
new IdentifiersModifiedSinceSearchBuilderBuiltEvent($this, $searchBuilder, $sinceDate),
);
/** @var AkeneoProductModel[] $productModels */
$productModels = $this->akeneoPimClient->getProductModelApi()->all(50, ['search' => $searchBuilder->getFilters()]);
$identifiers = [];
foreach ($productModels as $productModel) {
$productModelCode = $productModel['code'];
Assert::stringNotEmpty($productModelCode);
$identifiers[] = $productModelCode;
}

return $identifiers;
}

public function import(string $identifier): void
{
$searchBuilder = new SearchBuilder();
$searchBuilder->addFilter('parent', '=', $identifier);
$this->eventDispatcher->dispatch(
new ProductWithParentSearchBuilderBuiltEvent($this, $searchBuilder, $identifier),
);
/** @var AkeneoProduct[] $products */
$products = $this->akeneoPimClient->getProductApi()->all(50, ['search' => $searchBuilder->getFilters()]);

foreach ($products as $product) {
$this->messageBus->dispatch(new ItemImport(
ProductImporter::AKENEO_ENTITY,
$product['identifier'],
));
$this->messageBus->dispatch(new ItemImport(
ProductAssociationsImporter::AKENEO_ENTITY,
$product['identifier'],
));
}
}
}
1 change: 1 addition & 0 deletions tests/Behat/Context/Cli/ImportCommandContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ public function iShouldBeNotifiedThatTheSinceDateFileDoesNotExists(): void
/**
* @When I import all from Akeneo
* @When I import all "ProductAssociations" from Akeneo
* @When I import all "ProductModels" from Akeneo
*/
public function iImportAllItemsForAllImporters(?string $importer = null): void
{
Expand Down
19 changes: 19 additions & 0 deletions tests/Behat/Resources/suites.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ default:
filters:
tags: "@importing_products && @ui"

cli_importing_product_models:
contexts:
- sylius.behat.context.hook.doctrine_orm

- sylius.behat.context.setup.product
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.locale
- webgriffe_sylius_akeneo.behat.context.setup.akeneo_product
- webgriffe_sylius_akeneo.behat.context.setup.akeneo_product_model
- webgriffe_sylius_akeneo.behat.context.setup.akeneo_family_variant
- webgriffe_sylius_akeneo.behat.context.setup.akeneo_attribute

- webgriffe_sylius_akeneo.behat.context.db.product
- webgriffe_sylius_akeneo.behat.context.cli.import_command
- webgriffe_sylius_akeneo.behat.context.system.filesystem
- webgriffe_sylius_akeneo.behat.context.db.log
filters:
tags: "@importing_product_models && @cli"

cli_importing_product_associations:
contexts:
- sylius.behat.context.hook.doctrine_orm
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Sylius\Component\Locale\Model\Locale:
en_US:
code: "en_US"

Sylius\Component\Core\Model\Product:
product:
fallbackLocale: "en_US"
currentLocale: "en_US"
code: "STAR_WARS_TSHIRT"
variants:
- "@product-variant"

Sylius\Component\Core\Model\ProductVariant:
product-variant:
code: "STAR_WARS_TSHIRT_M"
product: "@product"

2 changes: 2 additions & 0 deletions tests/InMemory/Client/Api/InMemoryApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ abstract class InMemoryApi implements
*/
abstract public function getResources(): array;

abstract public static function clear(): void;

/** @return class-string */
abstract protected function getResourceClass(): string;

Expand Down
Loading
Loading