diff --git a/src/Command/UpdateInventoryCommand.php b/src/Command/UpdateInventoryCommand.php new file mode 100644 index 0000000..d46b744 --- /dev/null +++ b/src/Command/UpdateInventoryCommand.php @@ -0,0 +1,32 @@ +inventoryUpdater->updateAll(); + + return 0; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1bbe6f0..241c28f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,6 +4,7 @@ namespace Setono\SyliusPeakPlugin\DependencyInjection; +use Setono\SyliusPeakPlugin\Model\InventoryUpdate; use Setono\SyliusPeakPlugin\Model\RegisteredWebhooks; use Setono\SyliusPeakPlugin\Model\UploadOrderRequest; use Sylius\Component\Resource\Factory\Factory; @@ -42,6 +43,20 @@ private function addResourcesSection(ArrayNodeDefinition $node): void ->arrayNode('resources') ->addDefaultsIfNotSet() ->children() + ->arrayNode('inventory_update') + ->addDefaultsIfNotSet() + ->children() + ->variableNode('options')->end() + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('model')->defaultValue(InventoryUpdate::class)->cannotBeEmpty()->end() + ->scalarNode('repository')->cannotBeEmpty()->end() + ->scalarNode('factory')->defaultValue(Factory::class)->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('registered_webhooks') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/SetonoSyliusPeakExtension.php b/src/DependencyInjection/SetonoSyliusPeakExtension.php index ad46aad..0993160 100644 --- a/src/DependencyInjection/SetonoSyliusPeakExtension.php +++ b/src/DependencyInjection/SetonoSyliusPeakExtension.php @@ -6,6 +6,7 @@ use Setono\SyliusPeakPlugin\DataMapper\SalesOrderDataMapperInterface; use Setono\SyliusPeakPlugin\WebhookHandler\WebhookHandlerInterface; +use Setono\SyliusPeakPlugin\Workflow\InventoryUpdateWorkflow; use Setono\SyliusPeakPlugin\Workflow\UploadOrderRequestWorkflow; use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; @@ -58,7 +59,7 @@ public function prepend(ContainerBuilder $container): void ], ], ], - 'workflows' => UploadOrderRequestWorkflow::getConfig(), + 'workflows' => UploadOrderRequestWorkflow::getConfig() + InventoryUpdateWorkflow::getConfig(), ]); } } diff --git a/src/EventSubscriber/Workflow/InventoryUpdate/CompleteSubscriber.php b/src/EventSubscriber/Workflow/InventoryUpdate/CompleteSubscriber.php new file mode 100644 index 0000000..5fa619d --- /dev/null +++ b/src/EventSubscriber/Workflow/InventoryUpdate/CompleteSubscriber.php @@ -0,0 +1,34 @@ + 'set', + ]; + } + + public function set(CompletedEvent $event): void + { + /** @var InventoryUpdateInterface|object $inventoryUpdate */ + $inventoryUpdate = $event->getSubject(); + Assert::isInstanceOf($inventoryUpdate, InventoryUpdateInterface::class); + + $inventoryUpdate->setCompletedAt(new \DateTimeImmutable()); + + if (!$inventoryUpdate->hasErrors()) { + $inventoryUpdate->setNextUpdateThreshold($inventoryUpdate->getProcessingStartedAt()); + } + } +} diff --git a/src/EventSubscriber/Workflow/InventoryUpdate/ProcessSubscriber.php b/src/EventSubscriber/Workflow/InventoryUpdate/ProcessSubscriber.php new file mode 100644 index 0000000..daf2ee0 --- /dev/null +++ b/src/EventSubscriber/Workflow/InventoryUpdate/ProcessSubscriber.php @@ -0,0 +1,30 @@ + 'set', + ]; + } + + public function set(CompletedEvent $event): void + { + /** @var InventoryUpdateInterface|object $inventoryUpdate */ + $inventoryUpdate = $event->getSubject(); + Assert::isInstanceOf($inventoryUpdate, InventoryUpdateInterface::class); + + $inventoryUpdate->setProcessingStartedAt(new \DateTimeImmutable()); + } +} diff --git a/src/EventSubscriber/Workflow/InventoryUpdate/ResetSubscriber.php b/src/EventSubscriber/Workflow/InventoryUpdate/ResetSubscriber.php new file mode 100644 index 0000000..32927ad --- /dev/null +++ b/src/EventSubscriber/Workflow/InventoryUpdate/ResetSubscriber.php @@ -0,0 +1,33 @@ + 'reset', + ]; + } + + public function reset(CompletedEvent $event): void + { + /** @var InventoryUpdateInterface|object $inventoryUpdate */ + $inventoryUpdate = $event->getSubject(); + Assert::isInstanceOf($inventoryUpdate, InventoryUpdateInterface::class); + + $inventoryUpdate->setCompletedAt(null); + $inventoryUpdate->setProcessingStartedAt(null); + $inventoryUpdate->setWarnings(null); + $inventoryUpdate->setErrors(null); + } +} diff --git a/src/Message/Command/UpdateInventory.php b/src/Message/Command/UpdateInventory.php index b7e09cc..d5f1614 100644 --- a/src/Message/Command/UpdateInventory.php +++ b/src/Message/Command/UpdateInventory.php @@ -6,19 +6,28 @@ use Sylius\Component\Core\Model\ProductVariantInterface; -/** - * Will update the inventory for a product variant - */ final class UpdateInventory implements CommandInterface { - public int $productVariant; + public ?int $productVariant = null; - public function __construct(int|ProductVariantInterface $productVariant) + private function __construct() + { + } + + public static function for(int|ProductVariantInterface $productVariant): self { if ($productVariant instanceof ProductVariantInterface) { $productVariant = (int) $productVariant->getId(); } - $this->productVariant = $productVariant; + $command = new self(); + $command->productVariant = $productVariant; + + return $command; + } + + public static function forAll(): self + { + return new self(); } } diff --git a/src/Message/CommandHandler/UpdateInventoryHandler.php b/src/Message/CommandHandler/UpdateInventoryHandler.php index 49b016c..e92a4ab 100644 --- a/src/Message/CommandHandler/UpdateInventoryHandler.php +++ b/src/Message/CommandHandler/UpdateInventoryHandler.php @@ -4,9 +4,8 @@ namespace Setono\SyliusPeakPlugin\Message\CommandHandler; -use Setono\PeakWMS\Client\ClientInterface; -use Setono\PeakWMS\DataTransferObject\Product\Product; use Setono\SyliusPeakPlugin\Message\Command\UpdateInventory; +use Setono\SyliusPeakPlugin\Updater\InventoryUpdaterInterface; use Sylius\Component\Core\Model\ProductVariantInterface; use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; @@ -14,48 +13,24 @@ final class UpdateInventoryHandler { public function __construct( - private readonly ClientInterface $client, private readonly ProductVariantRepositoryInterface $productVariantRepository, + private readonly InventoryUpdaterInterface $inventoryUpdater, ) { } public function __invoke(UpdateInventory $message): void { - $productVariant = $this->productVariantRepository->find($message->productVariant); - if (!$productVariant instanceof ProductVariantInterface) { - throw new UnrecoverableMessageHandlingException(sprintf('Product variant with id %d not found', $message->productVariant)); - } - - $productCode = $productVariant->getProduct()?->getCode(); - $variantCode = $productVariant->getCode(); - - if (null === $productCode || null === $variantCode) { - throw new UnrecoverableMessageHandlingException(sprintf('Product variant with id %d does not have a product code or variant code', $message->productVariant)); - } - - $collection = $this - ->client - ->product() - ->getByProductId($productCode) - ->filter(fn (Product $product) => $product->variantId === $variantCode) - ; + if (null === $message->productVariant) { + $this->inventoryUpdater->updateAll(); - if (count($collection) === 0) { - throw new UnrecoverableMessageHandlingException(sprintf('The product with id %s does not have a variant with id/code %s', $productCode, $variantCode)); + return; } - if (count($collection) > 1) { - throw new UnrecoverableMessageHandlingException(sprintf('The product with id %s has multiple products with the same variant id/code', $productCode)); - } - - $peakProduct = $collection[0]; - - if (null === $peakProduct->availableToSell) { - throw new UnrecoverableMessageHandlingException(sprintf('The product with id %s and variant id/code %s does not have an availableToSell value', $productCode, $variantCode)); + $productVariant = $this->productVariantRepository->find($message->productVariant); + if (!$productVariant instanceof ProductVariantInterface) { + throw new UnrecoverableMessageHandlingException(sprintf('Product variant with id %d does not exist', $message->productVariant)); } - $productVariant->setOnHand($peakProduct->availableToSell + (int) $productVariant->getOnHold()); - - $this->productVariantRepository->add($productVariant); + $this->inventoryUpdater->update($productVariant); } } diff --git a/src/Model/InventoryUpdate.php b/src/Model/InventoryUpdate.php new file mode 100644 index 0000000..c475be6 --- /dev/null +++ b/src/Model/InventoryUpdate.php @@ -0,0 +1,141 @@ + */ + protected ?array $warnings = []; + + /** @var list */ + protected ?array $errors = []; + + public function getId(): ?int + { + return $this->id; + } + + public function getVersion(): int + { + return $this->version; + } + + public function setVersion(?int $version): void + { + $this->version = (int) $version; + } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): void + { + $this->state = $state; + } + + public function getProcessingStartedAt(): ?\DateTimeInterface + { + return $this->processingStartedAt; + } + + public function setProcessingStartedAt(?\DateTimeInterface $processingStartedAt): void + { + $this->processingStartedAt = $processingStartedAt; + } + + public function getCompletedAt(): ?\DateTimeInterface + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeInterface $completedAt): void + { + $this->completedAt = $completedAt; + } + + public function getNextUpdateThreshold(): ?\DateTimeInterface + { + return $this->nextUpdateThreshold; + } + + public function setNextUpdateThreshold(?\DateTimeInterface $nextUpdateThreshold): void + { + $this->nextUpdateThreshold = $nextUpdateThreshold; + } + + public function getProductsProcessed(): int + { + return $this->productsProcessed; + } + + public function setProductsProcessed(int $productsProcessed): void + { + $this->productsProcessed = $productsProcessed; + } + + public function addWarning(string $warning): void + { + $this->warnings[] = $warning; + } + + public function getWarnings(): array + { + return $this->warnings ?? []; + } + + public function setWarnings(?array $warnings): void + { + if ([] === $warnings) { + $warnings = null; + } + + $this->warnings = $warnings; + } + + public function hasWarnings(): bool + { + return [] !== $this->warnings; + } + + public function addError(string $error): void + { + $this->errors[] = $error; + } + + public function getErrors(): array + { + return $this->errors ?? []; + } + + public function setErrors(?array $errors): void + { + if ([] === $errors) { + $errors = null; + } + + $this->errors = $errors; + } + + public function hasErrors(): bool + { + return [] !== $this->errors; + } +} diff --git a/src/Model/InventoryUpdateInterface.php b/src/Model/InventoryUpdateInterface.php new file mode 100644 index 0000000..79ba6b2 --- /dev/null +++ b/src/Model/InventoryUpdateInterface.php @@ -0,0 +1,73 @@ + + */ + public function getWarnings(): array; + + public function setWarnings(?array $warnings): void; + + public function hasWarnings(): bool; + + /** + * @return list + */ + public function getErrors(): array; + + public function addError(string $error): void; + + /** + * @param list|null $errors + */ + public function setErrors(?array $errors): void; + + public function hasErrors(): bool; +} diff --git a/src/Provider/InventoryUpdateProvider.php b/src/Provider/InventoryUpdateProvider.php new file mode 100644 index 0000000..d8fb0b2 --- /dev/null +++ b/src/Provider/InventoryUpdateProvider.php @@ -0,0 +1,43 @@ +managerRegistry = $managerRegistry; + } + + public function getInventoryUpdate(): InventoryUpdateInterface + { + $inventoryUpdates = $this->managerRegistry->getRepository(InventoryUpdateInterface::class)->findAll(); + $c = count($inventoryUpdates); + + if ($c > 1) { + throw new \RuntimeException(sprintf( + 'Expected to find zero or one inventory update, but found %d', + $c, + )); + } + + if ($c === 1) { + return $inventoryUpdates[0]; + } + + $inventoryUpdate = $this->factory->createNew(); + Assert::isInstanceOf($inventoryUpdate, InventoryUpdateInterface::class); + + return $inventoryUpdate; + } +} diff --git a/src/Provider/InventoryUpdateProviderInterface.php b/src/Provider/InventoryUpdateProviderInterface.php new file mode 100644 index 0000000..5cc26b0 --- /dev/null +++ b/src/Provider/InventoryUpdateProviderInterface.php @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 3d1be15..83626b0 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -13,6 +13,7 @@ + diff --git a/src/Resources/config/services/client.xml b/src/Resources/config/services/client.xml index 094f9fb..5100b63 100644 --- a/src/Resources/config/services/client.xml +++ b/src/Resources/config/services/client.xml @@ -2,6 +2,8 @@ + + %setono_sylius_peak.api_key% diff --git a/src/Resources/config/services/command.xml b/src/Resources/config/services/command.xml index dc02511..1fe2cae 100644 --- a/src/Resources/config/services/command.xml +++ b/src/Resources/config/services/command.xml @@ -2,18 +2,22 @@ - + - + + + + + + + diff --git a/src/Resources/config/services/event_subscriber.xml b/src/Resources/config/services/event_subscriber.xml index eb6551a..1d57803 100644 --- a/src/Resources/config/services/event_subscriber.xml +++ b/src/Resources/config/services/event_subscriber.xml @@ -26,5 +26,18 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/services/message.xml b/src/Resources/config/services/message.xml index f608de5..127f983 100644 --- a/src/Resources/config/services/message.xml +++ b/src/Resources/config/services/message.xml @@ -19,5 +19,12 @@ + + + + + + + diff --git a/src/Resources/config/services/provider.xml b/src/Resources/config/services/provider.xml index 602e0ee..84f38df 100644 --- a/src/Resources/config/services/provider.xml +++ b/src/Resources/config/services/provider.xml @@ -14,5 +14,13 @@ %sylius.model.product_variant.class% + + + + + + + diff --git a/src/Resources/config/services/updater.xml b/src/Resources/config/services/updater.xml new file mode 100644 index 0000000..b40c760 --- /dev/null +++ b/src/Resources/config/services/updater.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/Updater/InventoryUpdater.php b/src/Updater/InventoryUpdater.php new file mode 100644 index 0000000..ff2b7c6 --- /dev/null +++ b/src/Updater/InventoryUpdater.php @@ -0,0 +1,129 @@ +managerRegistry = $managerRegistry; + } + + public function update(ProductVariantInterface $productVariant): void + { + $productCode = $productVariant->getProduct()?->getCode(); + $variantCode = $productVariant->getCode(); + + if (null === $productCode || null === $variantCode) { + throw new \RuntimeException(sprintf( + 'Product variant with id %d does not have a product code or variant code', + (int) $productVariant->getId(), + )); + } + + $collection = $this + ->client + ->product() + ->getByProductId($productCode) + ->filter(fn (Product $product) => $product->variantId === $variantCode) + ; + + if (count($collection) !== 1) { + throw new \RuntimeException(sprintf( + 'The product with id %s either does not have a variant with id/code %s or has multiple products with the same variant id/code', + $productCode, + $variantCode, + )); + } + + $this->map($collection[0], $productVariant); + + $this->getManager($productVariant)->flush(); + } + + // todo what happens if the transitions are not possible? + public function updateAll(bool $onlyUpdated = true): void + { + $inventoryUpdate = $this->inventoryUpdateProvider->getInventoryUpdate(); + $this->getManager($inventoryUpdate)->persist($inventoryUpdate); + + $this->transition($inventoryUpdate, InventoryUpdateWorkflow::TRANSITION_RESET); + $this->transition($inventoryUpdate, InventoryUpdateWorkflow::TRANSITION_PROCESS); + + $manager = $this->getManager(ProductVariant::class); + $productVariantRepository = $this->getRepository(ProductVariant::class); + + $i = 0; + $products = $this->client->product()->iterate(PageQuery::create(updatedAfter: $inventoryUpdate->getNextUpdateThreshold())); + foreach ($products as $product) { + ++$i; + + if ($i % 100 === 0) { + $manager->flush(); + $manager->clear(); + } + + try { + Assert::notNull($product->variantId, sprintf( + 'Product with id %d does not have a variant id. It is expected that Peak WMS has the same structure of products as Sylius, namely that all products at least have one variant.', + (int) $product->id, + )); + + $productVariant = $productVariantRepository->findOneBy(['code' => $product->variantId]); + Assert::notNull($productVariant, sprintf('Product variant with code %s does not exist', $product->variantId)); + + if ($product->orderedByCustomers !== $productVariant->getOnHold()) { + $inventoryUpdate->addWarning(sprintf( + 'Product variant with code %s has %d on hold in Sylius and %d on hold in Peak WMS', + $product->variantId, + (int) $productVariant->getOnHold(), + (int) $product->orderedByCustomers, + )); + } + + $this->map($product, $productVariant); + } catch (\InvalidArgumentException $e) { + $inventoryUpdate->addError($e->getMessage()); + } + } + + $inventoryUpdate->setProductsProcessed($i); + + $manager->flush(); + + $this->transition($inventoryUpdate, InventoryUpdateWorkflow::TRANSITION_COMPLETE); + } + + private function transition(InventoryUpdateInterface $inventoryUpdate, string $transition): void + { + $this->inventoryUpdateWorkflow->apply($inventoryUpdate, $transition); + + $this->getManager($inventoryUpdate)->flush(); + } + + private function map(Product $product, ProductVariantInterface $productVariant): void + { + $productVariant->setOnHand((int) $product->availableToSell + (int) $productVariant->getOnHold()); + } +} diff --git a/src/Updater/InventoryUpdaterInterface.php b/src/Updater/InventoryUpdaterInterface.php new file mode 100644 index 0000000..26a4a0a --- /dev/null +++ b/src/Updater/InventoryUpdaterInterface.php @@ -0,0 +1,22 @@ +variantId)); } - $this->commandBus->dispatch(new UpdateInventory($productVariant)); + $this->commandBus->dispatch(UpdateInventory::for($productVariant)); } /** diff --git a/src/Workflow/InventoryUpdateWorkflow.php b/src/Workflow/InventoryUpdateWorkflow.php new file mode 100644 index 0000000..4afee97 --- /dev/null +++ b/src/Workflow/InventoryUpdateWorkflow.php @@ -0,0 +1,86 @@ + + */ + public static function getStates(): array + { + return [ + InventoryUpdateInterface::STATE_PENDING, + InventoryUpdateInterface::STATE_PROCESSING, + InventoryUpdateInterface::STATE_COMPLETED, + ]; + } + + public static function getConfig(): array + { + $transitions = []; + foreach (self::getTransitions() as $transition) { + $transitions[$transition->getName()] = [ + 'from' => $transition->getFroms(), + 'to' => $transition->getTos(), + ]; + } + + return [ + self::NAME => [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'method', + 'property' => self::PROPERTY_NAME, + ], + 'supports' => InventoryUpdateInterface::class, + 'initial_marking' => InventoryUpdateInterface::STATE_PENDING, + 'places' => self::getStates(), + 'transitions' => $transitions, + ], + ]; + } + + /** + * @return array + */ + public static function getTransitions(): array + { + return [ + new Transition( + self::TRANSITION_PROCESS, + InventoryUpdateInterface::STATE_PENDING, + InventoryUpdateInterface::STATE_PROCESSING, + ), + new Transition( + self::TRANSITION_COMPLETE, + InventoryUpdateInterface::STATE_PROCESSING, + InventoryUpdateInterface::STATE_COMPLETED, + ), + new Transition( + self::TRANSITION_RESET, + [InventoryUpdateInterface::STATE_PENDING, InventoryUpdateInterface::STATE_COMPLETED], + InventoryUpdateInterface::STATE_PENDING, + ), + ]; + } +}