custom/plugins/PickwareErpStarter/src/Stock/ProductStockUpdater.php line 49

Open in your IDE?
  1. <?php
  2. /*
  3.  * Copyright (c) Pickware GmbH. All rights reserved.
  4.  * This file is part of software that is released under a proprietary license.
  5.  * You must not copy, modify, distribute, make publicly available, or execute
  6.  * its contents or parts thereof without express permission by the copyright
  7.  * holder, unless otherwise permitted by law.
  8.  */
  9. declare(strict_types=1);
  10. namespace Pickware\PickwareErpStarter\Stock;
  11. use Doctrine\DBAL\Connection;
  12. use Doctrine\DBAL\Exception;
  13. use Pickware\DalBundle\RetryableTransaction;
  14. use Pickware\PickwareErpStarter\Stock\Model\LocationTypeDefinition;
  15. use Pickware\PickwareErpStarter\Stock\Model\StockMovementDefinition;
  16. use Pickware\PickwareErpStarter\Warehouse\Model\ProductWarehouseConfigurationDefinition;
  17. use Shopware\Core\Defaults;
  18. use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
  19. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\ChangeSetAware;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  22. use Shopware\Core\Framework\Uuid\Uuid;
  23. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  24. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  25. class ProductStockUpdater implements EventSubscriberInterface
  26. {
  27.     private Connection $db;
  28.     private EventDispatcherInterface $eventDispatcher;
  29.     public function __construct(Connection $dbEventDispatcherInterface $eventDispatcher)
  30.     {
  31.         $this->db $db;
  32.         $this->eventDispatcher $eventDispatcher;
  33.     }
  34.     public static function getSubscribedEvents(): array
  35.     {
  36.         return [
  37.             PreWriteValidationEvent::class => 'preWriteValidation',
  38.             StockMovementDefinition::ENTITY_WRITTEN_EVENT => 'stockMovementWritten',
  39.             ProductWarehouseConfigurationDefinition::ENTITY_WRITTEN_EVENT => 'productWarehouseConfigurationWritten',
  40.         ];
  41.     }
  42.     public function preWriteValidation(PreWriteValidationEvent $event): void
  43.     {
  44.         foreach ($event->getCommands() as $command) {
  45.             if ($command instanceof ChangeSetAware) {
  46.                 $command->requestChangeSet();
  47.             }
  48.         }
  49.     }
  50.     public function stockMovementWritten(EntityWrittenEvent $entityWrittenEvent): void
  51.     {
  52.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  53.             return;
  54.         }
  55.         $stockMovementIds = [];
  56.         foreach ($entityWrittenEvent->getWriteResults() as $writeResult) {
  57.             if ($writeResult->getExistence()->exists()) {
  58.                 // Updating stock movements is not supported yet
  59.                 // In case a stock location is deleted, this code path is also reached. This is because an
  60.                 // EntityWrittenEvent is triggered when an entity field gets null-ed because of a SET NULL constraint
  61.                 // of a FK.
  62.                 continue;
  63.             }
  64.             $payload $writeResult->getPayload();
  65.             $stockMovementIds[] = $payload['id'];
  66.         }
  67.         $this->indexStockMovements($stockMovementIds);
  68.     }
  69.     public function indexStockMovements(array $stockMovementIds): void
  70.     {
  71.         $stockMovementIds array_values(array_unique($stockMovementIds));
  72.         $stockMovements $this->db->fetchAllAssociative(
  73.             'SELECT
  74.                 LOWER(HEX(id)) AS id,
  75.                 LOWER(HEX(product_id)) AS productId,
  76.                 LOWER(HEX(product_version_id)) AS productVersionId,
  77.                 source_location_type_technical_name AS sourceLocationTypeTechnicalName,
  78.                 LOWER(HEX(source_warehouse_id)) AS sourceWarehouseId,
  79.                 LOWER(HEX(source_bin_location_id)) AS sourceBinLocationId,
  80.                 LOWER(HEX(source_order_id)) AS sourceOrderId,
  81.                 LOWER(HEX(source_order_version_id)) AS sourceOrderVersionId,
  82.                 LOWER(HEX(source_supplier_order_id)) AS sourceSupplierOrderId,
  83.                 LOWER(HEX(source_stock_container_id)) AS sourceStockContainerId,
  84.                 LOWER(HEX(source_return_order_id)) AS sourceReturnOrderId,
  85.                 LOWER(HEX(source_return_order_version_id)) AS sourceReturnOrderVersionId,
  86.                 source_special_stock_location_technical_name AS sourceSpecialStockLocationTechnicalName,
  87.                 destination_location_type_technical_name AS destinationLocationTypeTechnicalName,
  88.                 LOWER(HEX(destination_warehouse_id)) AS destinationWarehouseId,
  89.                 LOWER(HEX(destination_bin_location_id)) AS destinationBinLocationId,
  90.                 LOWER(HEX(destination_order_id)) AS destinationOrderId,
  91.                 LOWER(HEX(destination_order_version_id)) AS destinationOrderVersionId,
  92.                 LOWER(HEX(destination_supplier_order_id)) AS destinationSupplierOrderId,
  93.                 LOWER(HEX(destination_stock_container_id)) AS destinationStockContainerId,
  94.                 LOWER(HEX(destination_return_order_id)) AS destinationReturnOrderId,
  95.                 LOWER(HEX(destination_return_order_version_id)) AS destinationReturnOrderVersionId,
  96.                 destination_special_stock_location_technical_name AS destinationSpecialStockLocationTechnicalName,
  97.                 quantity
  98.             FROM pickware_erp_stock_movement
  99.             WHERE id IN (:stockMovementIds) AND product_version_id = :liveVersionId',
  100.             [
  101.                 'stockMovementIds' => array_map('hex2bin'$stockMovementIds),
  102.                 'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  103.             ],
  104.             [
  105.                 'stockMovementIds' => Connection::PARAM_STR_ARRAY,
  106.             ],
  107.         );
  108.         $stockMovements array_combine(array_column($stockMovements'id'), $stockMovements);
  109.         RetryableTransaction::retryable($this->db, function () use ($stockMovements): void {
  110.             $productIds = [];
  111.             foreach ($stockMovements as $stockMovement) {
  112.                 $this->persistStockChange(
  113.                     [
  114.                         'productId' => $stockMovement['productId'],
  115.                         'productVersionId' => $stockMovement['productVersionId'],
  116.                         'locationTypeTechnicalName' => $stockMovement['sourceLocationTypeTechnicalName'],
  117.                         'warehouseId' => $stockMovement['sourceWarehouseId'] ?? null,
  118.                         'binLocationId' => $stockMovement['sourceBinLocationId'] ?? null,
  119.                         'orderId' => $stockMovement['sourceOrderId'] ?? null,
  120.                         'orderVersionId' => $stockMovement['sourceOrderVersionId'] ?? null,
  121.                         'supplierOrderId' => $stockMovement['sourceSupplierOrderId'] ?? null,
  122.                         'stockContainerId' => $stockMovement['sourceStockContainerId'] ?? null,
  123.                         'returnOrderId' => $stockMovement['sourceReturnOrderId'] ?? null,
  124.                         'returnOrderVersionId' => $stockMovement['sourceReturnOrderVersionId'] ?? null,
  125.                         'specialStockLocationTechnicalName' => $stockMovement['sourceSpecialStockLocationTechnicalName'] ?? null,
  126.                         'changeAmount' => -$stockMovement['quantity'],
  127.                     ],
  128.                 );
  129.                 $this->persistStockChange(
  130.                     [
  131.                         'productId' => $stockMovement['productId'],
  132.                         'productVersionId' => $stockMovement['productVersionId'],
  133.                         'locationTypeTechnicalName' => $stockMovement['destinationLocationTypeTechnicalName'],
  134.                         'warehouseId' => $stockMovement['destinationWarehouseId'] ?? null,
  135.                         'binLocationId' => $stockMovement['destinationBinLocationId'] ?? null,
  136.                         'orderId' => $stockMovement['destinationOrderId'] ?? null,
  137.                         'orderVersionId' => $stockMovement['destinationOrderVersionId'] ?? null,
  138.                         'supplierOrderId' => $stockMovement['destinationSupplierOrderId'] ?? null,
  139.                         'stockContainerId' => $stockMovement['destinationStockContainerId'] ?? null,
  140.                         'returnOrderId' => $stockMovement['destinationReturnOrderId'] ?? null,
  141.                         'returnOrderVersionId' => $stockMovement['destinationReturnOrderVersionId'] ?? null,
  142.                         'specialStockLocationTechnicalName' => $stockMovement['destinationSpecialStockLocationTechnicalName'] ?? null,
  143.                         'changeAmount' => $stockMovement['quantity'],
  144.                     ],
  145.                 );
  146.                 $productIds[] = $stockMovement['productId'];
  147.             }
  148.             $this->cleanUpStocks($productIds);
  149.             $this->recalculateProductStock($productIds);
  150.         });
  151.         $this->eventDispatcher->dispatch(
  152.             new StockUpdatedForStockMovementsEvent($stockMovements),
  153.             StockUpdatedForStockMovementsEvent::EVENT_NAME,
  154.         );
  155.     }
  156.     public function productWarehouseConfigurationWritten(EntityWrittenEvent $entityWrittenEvent): void
  157.     {
  158.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  159.             return;
  160.         }
  161.         $writeResults $entityWrittenEvent->getWriteResults();
  162.         $oldProductBinLocations = [];
  163.         $newProductBinLocations = [];
  164.         foreach ($writeResults as $writeResult) {
  165.             $changeSet $writeResult->getChangeSet();
  166.             $payload $writeResult->getPayload();
  167.             if ($changeSet && $changeSet->hasChanged('default_bin_location_id')) {
  168.                 $productId $changeSet->getBefore('product_id');
  169.                 $oldDefaultBinLocationId $changeSet->getBefore('default_bin_location_id');
  170.                 if ($oldDefaultBinLocationId) {
  171.                     $oldProductBinLocations[] = new ProductBinLocation(bin2hex($productId), bin2hex($oldDefaultBinLocationId));
  172.                 }
  173.                 $newDefaultBinLocationId $changeSet->getAfter('default_bin_location_id');
  174.                 if ($newDefaultBinLocationId) {
  175.                     $newProductBinLocations[] = new ProductBinLocation(bin2hex($productId), bin2hex($newDefaultBinLocationId));
  176.                 }
  177.             } elseif ($writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT) {
  178.                 $defaultBinLocationId $payload['defaultBinLocationId'] ?? null;
  179.                 if ($defaultBinLocationId) {
  180.                     $newProductBinLocations[] = new ProductBinLocation($payload['productId'], $defaultBinLocationId);
  181.                 }
  182.             }
  183.         }
  184.         $this->deleteStockEntriesForOldDefaultBinLocations($oldProductBinLocations);
  185.         $this->upsertStockEntriesForDefaultBinLocations($newProductBinLocations);
  186.     }
  187.     public function upsertStockEntriesForDefaultBinLocationsOfProducts(array $productIds): void
  188.     {
  189.         $configurations $this->db->fetchAllAssociative(
  190.             'SELECT
  191.                 LOWER(HEX(product_id)) AS productId,
  192.                 LOWER(HEX(default_bin_location_id)) AS binLocationId
  193.             FROM pickware_erp_product_warehouse_configuration
  194.             WHERE product_id IN (:productIds)
  195.                 AND product_version_id = :liveVersionId
  196.                 AND default_bin_location_id IS NOT NULL',
  197.             [
  198.                 'productIds' => array_map('hex2bin'$productIds),
  199.                 'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  200.             ],
  201.             [
  202.                 'productIds' => Connection::PARAM_STR_ARRAY,
  203.             ],
  204.         );
  205.         $productBinLocations array_map(static fn (array $configuration) => new ProductBinLocation($configuration['productId'], $configuration['binLocationId']), $configurations);
  206.         $this->upsertStockEntriesForDefaultBinLocations($productBinLocations);
  207.     }
  208.     /**
  209.      * @param ProductBinLocation[] $productBinLocations
  210.      * @throws Exception
  211.      */
  212.     private function upsertStockEntriesForDefaultBinLocations(array $productBinLocations): void
  213.     {
  214.         if (count($productBinLocations) > 0) {
  215.             $tuples implode(', 'array_map(static function (ProductBinLocation $productBinLocation) {
  216.                 return sprintf(
  217.                     '(UNHEX(\'%s\'), UNHEX(\'%s\'), UNHEX(\'%s\'), "%s", UNHEX(\'%s\'), 0, NOW())',
  218.                     Uuid::randomHex(),
  219.                     $productBinLocation->getProductId(),
  220.                     Defaults::LIVE_VERSION,
  221.                     LocationTypeDefinition::TECHNICAL_NAME_BIN_LOCATION,
  222.                     $productBinLocation->getBinLocationId(),
  223.                 );
  224.             }, $productBinLocations));
  225.             $query sprintf(
  226.                 'INSERT IGNORE INTO `pickware_erp_stock`
  227.                 (
  228.                     `id`,
  229.                     `product_id`,
  230.                     `product_version_id`,
  231.                     `location_type_technical_name`,
  232.                     `bin_location_id`,
  233.                     `quantity`,
  234.                     `created_at`
  235.                 ) VALUES %s',
  236.                 $tuples,
  237.             );
  238.             $this->db->executeStatement($query);
  239.         }
  240.     }
  241.     /**
  242.      * Deletes stock entries for the given default bin location and products if it has no stock.
  243.      *
  244.      * @param ProductBinLocation[] $productBinLocations
  245.      * @throws Exception
  246.      */
  247.     private function deleteStockEntriesForOldDefaultBinLocations(array $productBinLocations): void
  248.     {
  249.         if (count($productBinLocations) > 0) {
  250.             $tuples implode(', 'array_map(static function (ProductBinLocation $productBinLocation) {
  251.                 return sprintf(
  252.                     '(UNHEX(\'%s\'), UNHEX(\'%s\'))',
  253.                     $productBinLocation->getProductId(),
  254.                     $productBinLocation->getBinLocationId(),
  255.                 );
  256.             }, $productBinLocations));
  257.             $query sprintf(
  258.                 'DELETE `pickware_erp_stock` FROM `pickware_erp_stock`
  259.                 WHERE `pickware_erp_stock`.`quantity` = 0
  260.                 AND `pickware_erp_stock`.`product_version_id` = :liveVersionId
  261.                 AND (`pickware_erp_stock`.`product_id`, `pickware_erp_stock`.`bin_location_id`) IN (%s)',
  262.                 $tuples,
  263.             );
  264.             $this->db->executeStatement(
  265.                 $query,
  266.                 ['liveVersionId' => hex2bin(Defaults::LIVE_VERSION)],
  267.             );
  268.         }
  269.     }
  270.     private function persistStockChange(array $payload): void
  271.     {
  272.         $this->db->executeStatement(
  273.             'INSERT INTO pickware_erp_stock (
  274.                 id,
  275.                 product_id,
  276.                 product_version_id,
  277.                 quantity,
  278.                 location_type_technical_name,
  279.                 warehouse_id,
  280.                 bin_location_id,
  281.                 order_id,
  282.                 order_version_id,
  283.                 supplier_order_id,
  284.                 stock_container_id,
  285.                 return_order_id,
  286.                 return_order_version_id,
  287.                 special_stock_location_technical_name,
  288.                 created_at
  289.             ) VALUES (
  290.                 :id,
  291.                 :productId,
  292.                 :productVersionId,
  293.                 :changeAmount,
  294.                 :locationTypeTechnicalName,
  295.                 :warehouseId,
  296.                 :binLocationId,
  297.                 :orderId,
  298.                 :orderVersionId,
  299.                 :supplierOrderId,
  300.                 :stockContainerId,
  301.                 :returnOrderId,
  302.                 :returnOrderVersionId,
  303.                 :specialStockLocationTechnicalName,
  304.                 NOW(3)
  305.             ) ON DUPLICATE KEY UPDATE
  306.                 quantity = quantity + VALUES(quantity),
  307.                 updated_at = NOW(3)',
  308.             [
  309.                 'id' => Uuid::randomBytes(),
  310.                 'locationTypeTechnicalName' => $payload['locationTypeTechnicalName'],
  311.                 'productId' => hex2bin($payload['productId']),
  312.                 'productVersionId' => hex2bin($payload['productVersionId']),
  313.                 'warehouseId' => $payload['warehouseId'] ? hex2bin($payload['warehouseId']) : null,
  314.                 'binLocationId' => $payload['binLocationId'] ? hex2bin($payload['binLocationId']) : null,
  315.                 'orderId' => $payload['orderId'] ? hex2bin($payload['orderId']) : null,
  316.                 'orderVersionId' => $payload['orderVersionId'] ? hex2bin($payload['orderVersionId']) : null,
  317.                 'supplierOrderId' => $payload['supplierOrderId'] ? hex2bin($payload['supplierOrderId']) : null,
  318.                 'stockContainerId' => $payload['stockContainerId'] ? hex2bin($payload['stockContainerId']) : null,
  319.                 'returnOrderId' => $payload['returnOrderId'] ? hex2bin($payload['returnOrderId']) : null,
  320.                 'returnOrderVersionId' => $payload['returnOrderVersionId'] ? hex2bin($payload['returnOrderVersionId']) : null,
  321.                 'specialStockLocationTechnicalName' => $payload['specialStockLocationTechnicalName'],
  322.                 'changeAmount' => $payload['changeAmount'],
  323.             ],
  324.         );
  325.     }
  326.     private function cleanUpStocks(array $productIds): void
  327.     {
  328.         $this->db->executeStatement(
  329.             'DELETE `stock`
  330.             FROM `pickware_erp_stock` AS `stock`
  331.             LEFT JOIN `pickware_erp_product_warehouse_configuration` AS `product_warehouse_configuration`
  332.                 ON `stock`.`product_id` = `product_warehouse_configuration`.product_id
  333.                     AND `stock`.`bin_location_id` = `product_warehouse_configuration`.`default_bin_location_id`
  334.             WHERE `stock`.`quantity` = 0
  335.             AND `stock`.`product_version_id` = :liveVersionId
  336.             AND `stock`.`product_id` IN (:productIds)
  337.             AND `product_warehouse_configuration`.`default_bin_location_id` IS NULL
  338.             ',
  339.             [
  340.                 'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  341.                 'productIds' => array_map('hex2bin'$productIds),
  342.             ],
  343.             [
  344.                 'productIds' => Connection::PARAM_STR_ARRAY,
  345.             ],
  346.         );
  347.     }
  348.     private function recalculateProductStock(array $productIds): void
  349.     {
  350.         $query '
  351.             UPDATE `product`
  352.             LEFT JOIN (
  353.                 SELECT
  354.                     `stock`.`product_id` as `product_id`,
  355.                     `stock`.`product_version_id` as `product_version_id`,
  356.                     SUM(`stock`.`quantity`) AS `quantity`
  357.                 FROM `pickware_erp_stock` `stock`
  358.                 LEFT JOIN `pickware_erp_location_type` AS `location_type`
  359.                     ON `stock`.`location_type_technical_name` = `location_type`.`technical_name`
  360.                 WHERE `location_type`.`internal` = 1
  361.                 AND `stock`.`product_id` IN (:productIds) AND `stock`.`product_version_id` = :liveVersionId
  362.                 GROUP BY
  363.                     `stock`.`product_id`,
  364.                     `stock`.`product_version_id`
  365.             ) AS `totalStocks`
  366.                 ON
  367.                     `totalStocks`.`product_id` = `product`.`id`
  368.                     AND `totalStocks`.`product_version_id` = `product`.`version_id`
  369.             SET
  370.                 -- The following "term" updates the stock and the available stock such that the "reserved stock" stays
  371.                 -- constant.
  372.                 `product`.`available_stock` =
  373.                     -- <=> stock - reserved stock
  374.                     IFNULL(`totalStocks`.`quantity`, 0) - (`product`.`stock` - `product`.`available_stock`),
  375.                 `product`.`stock` = IFNULL(`totalStocks`.`quantity`, 0)
  376.             WHERE `product`.`version_id` = :liveVersionId
  377.             AND `product`.`id` IN (:productIds)';
  378.         $params = [
  379.             'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  380.             'productIds' => array_map('hex2bin'$productIds),
  381.         ];
  382.         $paramTypes = [
  383.             'productIds' => Connection::PARAM_STR_ARRAY,
  384.         ];
  385.         $this->db->executeStatement($query$params$paramTypes);
  386.         $this->eventDispatcher->dispatch(
  387.             new ProductAvailableStockUpdatedEvent($productIds),
  388.             ProductAvailableStockUpdatedEvent::EVENT_NAME,
  389.         );
  390.     }
  391. }