<?php
declare(strict_types=1);
namespace Wbfk\Bundles\Core\Content\Product\DataAbstractionLayer;
use DateTime;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Shopware\Core\Checkout\Cart\Event\CheckoutOrderPlacedEvent;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Order\OrderEvents;
use Shopware\Core\Checkout\Order\OrderStates;
use Shopware\Core\Content\Product\Events\ProductNoLongerAvailableEvent;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery;
use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Profiling\Profiler;
use Shopware\Core\System\StateMachine\Event\StateMachineTransitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use function array_key_exists;
use function array_keys;
use function in_array;
/*
* Bundle stock updater is based on \Shopware\Core\Content\Product\DataAbstractionLayer\StockUpdater for bundle stock calculation.
* We have filtered bundle children and bundle parents from Shopware StockUpdater using \Wbfk\Bundles\Core\Content\Product\DataAbstractionLayer\BundleStockUpdateFilter
* So that their stocks are not updated by Shopware StockUpdater.
*/
class BundleStockUpdater implements EventSubscriberInterface
{
public function __construct(
private readonly Connection $connection,
private readonly EventDispatcherInterface $dispatcher,
private readonly BundleFilter $bundleFilter,
) {
}
public static function getSubscribedEvents(): array
{
return [
CheckoutOrderPlacedEvent::class => 'orderPlaced',
StateMachineTransitionEvent::class => 'stateChanged',
OrderEvents::ORDER_LINE_ITEM_WRITTEN_EVENT => 'lineItemWritten',
OrderEvents::ORDER_LINE_ITEM_DELETED_EVENT => 'lineItemWritten',
];
}
/**
* @throws Exception
*/
public function orderPlaced(CheckoutOrderPlacedEvent $event): void
{
/** @noinspection DuplicatedCode */
$orderedProductQuantities = [];
foreach ($event->getOrder()->getLineItems() ?? [] as $lineItem) {
if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
continue;
}
$referencedId = $lineItem->getReferencedId();
if (!$referencedId) {
continue;
}
if (!array_key_exists($referencedId, $orderedProductQuantities)) {
$orderedProductQuantities[$referencedId] = 0;
}
$orderedProductQuantities[$referencedId] += $lineItem->getQuantity();
}
/*
* Foreach bundle product, we need to find child products and their quantity in the bundle.
* Then add these child products to the list of ordered products with the quantity of the parent product multiplied by the quantity of the child product in the bundle.
* After that, we can remove the parent product from the list.
*
* Bundle product's available stock is calculated based on the available stock of child products.
* This is done in the final step after updating the available stock of child products.
*/
$orderedBundleIds = $this->bundleFilter->filterParentProductIds(array_keys($orderedProductQuantities), $event->getContext());
$bundleChildrenQuantities = $this->bundleFilter->findBundleChildrenQuantities($orderedBundleIds, $event->getContext());
foreach ($orderedBundleIds as $bundleId) {
$childIds = array_keys($bundleChildrenQuantities[$bundleId]);
$childQuantities = $bundleChildrenQuantities[$bundleId];
foreach ($childIds as $childId) {
if (!array_key_exists($childId, $orderedProductQuantities)) {
$orderedProductQuantities[$childId] = 0;
}
$orderedProductQuantities[$childId] += $childQuantities[$childId] * $orderedProductQuantities[$bundleId];
}
// Now that we have updated the quantity of child products in the bundle, we can remove the parent product from the list
unset($orderedProductQuantities[$bundleId]);
}
/*
* Shopware has already calculated available stock of normal products, we need to calculate available stock of bundle children
* After that, we can calculate available stock of bundles whose children's available stock is updated
*/
$orderedBundleChildrenIds = $this->bundleFilter->filterBundleChildrenIds(array_keys($orderedProductQuantities), $event->getContext());
$orderedBundleChildrenQuantities = array_intersect_key($orderedProductQuantities, array_flip($orderedBundleChildrenIds));
$query = new RetryableQuery(
$this->connection,
$this->connection->prepare('UPDATE product SET available_stock = available_stock - :quantity WHERE id = :id')
);
foreach ($orderedBundleChildrenQuantities as $id => $quantity) {
/** @noinspection PhpDeprecationInspection */
$query->execute(['id' => Uuid::fromHexToBytes((string)$id), 'quantity' => $quantity]);
}
$this->updateAvailableFlag(array_keys($orderedBundleChildrenQuantities), $event->getContext());
$bundleIds = $this->bundleFilter->findParentIds($orderedBundleChildrenIds, $event->getContext());
$this->updateBundleStockAndAvailability($bundleIds, $event->getContext());
}
/**
* If the product of an order item changed, the stocks of the old product and the new product must be updated.
* @throws Exception
*/
public function lineItemWritten(EntityWrittenEvent $event): void
{
/** @noinspection DuplicatedCode */
$ids = [];
// we don't want to trigger to `update` method when we are inside the order process
if ($event->getContext()->hasState('checkout-order-route')) {
return;
}
foreach ($event->getWriteResults() as $result) {
if ($result->hasPayload('referencedId') && $result->getProperty('type') === LineItem::PRODUCT_LINE_ITEM_TYPE) {
$ids[] = $result->getProperty('referencedId');
}
if ($result->getOperation() === EntityWriteResult::OPERATION_INSERT) {
continue;
}
$changeSet = $result->getChangeSet();
if (!$changeSet) {
continue;
}
$type = $changeSet->getBefore('type');
if ($type !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
continue;
}
if (!$changeSet->hasChanged('referenced_id') && !$changeSet->hasChanged('quantity')) {
continue;
}
$ids[] = $changeSet->getBefore('referenced_id');
$ids[] = $changeSet->getAfter('referenced_id');
}
$ids = array_filter(array_unique($ids));
if (empty($ids)) {
return;
}
$this->update($ids, $event->getContext());
}
/**
* @throws \Doctrine\DBAL\Driver\Exception
* @throws Exception
*/
public function stateChanged(StateMachineTransitionEvent $event): void
{
/** @noinspection DuplicatedCode */
if ($event->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
return;
}
if ($event->getEntityName() !== 'order') {
return;
}
if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
/*
* Stock decrease for bundle children, then bundle parents based on the available stock of bundle children
*/
$this->decreaseStock($event);
return;
}
if ($event->getFromPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
/*
* Stock increase for bundle children, then bundle parents based on the available stock of bundle children
*/
$this->increaseStock($event);
return;
}
if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED || $event->getFromPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED) {
[
'parentProductIds' => $parentProductIds,
'childProductIds' => $childProductIds,
] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
$ids = array_merge($childProductIds, $parentProductIds);
$this->update($ids, $event->getContext());
}
}
/**
* @throws Exception
* @throws \Doctrine\DBAL\Driver\Exception
*/
private function increaseStock(StateMachineTransitionEvent $event): void
{
[
'lineItems' => $lineItems,
'parentProductIds' => $parentProductIds,
'childProductIds' => $childProductIds,
] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
// Update stock of bundle children which are directly in the order
$childProductLineItems = array_filter($lineItems, static fn(array $item) => in_array($item['referenced_id'], $childProductIds, true));
$this->updateStock($childProductLineItems, +1);
// Update stock of bundle children which are part of the order because their parent product is in the order
$bundleChildrenQuantities = $this->bundleFilter->findBundleChildrenQuantities($parentProductIds, $event->getContext());
$childProductLineItemsFromParents = $this->getChildProductLineItemsFromParents($parentProductIds, $lineItems, $bundleChildrenQuantities);
$this->updateStock($childProductLineItemsFromParents, +1);
$ids = array_merge($childProductIds, array_keys($childProductLineItemsFromParents));
$this->update($ids, $event->getContext());
}
/**
* @throws Exception
* @throws \Doctrine\DBAL\Driver\Exception
*/
private function decreaseStock(StateMachineTransitionEvent $event): void
{
[
'lineItems' => $lineItems,
'parentProductIds' => $parentProductIds,
'childProductIds' => $childProductIds,
] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
// Update stock of bundle children which are directly in the order
$childProductLineItems = array_filter($lineItems, static fn(array $item) => in_array($item['referenced_id'], $childProductIds, true));
$this->updateStock($childProductLineItems, -1);
// Update stock of bundle children which are part of the order because their parent product is in the order
$bundleChildrenQuantities = $this->bundleFilter->findBundleChildrenQuantities($parentProductIds, $event->getContext());
$childProductLineItemsFromParents = $this->getChildProductLineItemsFromParents($parentProductIds, $lineItems, $bundleChildrenQuantities);
$this->updateStock($childProductLineItemsFromParents, -1);
$ids = array_merge($childProductIds, array_keys($childProductLineItemsFromParents));
$this->update($ids, $event->getContext());
}
/**
* @param list<string> $ids
* @throws Exception
*/
private function updateAvailableFlag(array $ids, Context $context): void
{
/** @noinspection DuplicatedCode */
$ids = array_filter(array_unique($ids));
if (empty($ids)) {
return;
}
$bytes = Uuid::fromHexToBytesList($ids);
$sql = '
UPDATE product
LEFT JOIN product parent
ON parent.id = product.parent_id
AND parent.version_id = product.version_id
SET product.available = IFNULL((
IFNULL(product.is_closeout, parent.is_closeout) * product.available_stock
>=
IFNULL(product.is_closeout, parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase)
), 0)
WHERE product.id IN (:ids)
AND product.version_id = :version
';
/** @noinspection PhpDeprecationInspection */
RetryableQuery::retryable($this->connection, function () use ($sql, $context, $bytes): void {
$this->connection->executeStatement(
$sql,
['ids' => $bytes, 'version' => Uuid::fromHexToBytes($context->getVersionId())],
['ids' => Connection::PARAM_STR_ARRAY]
);
});
$updated = $this->connection->fetchFirstColumn(
'SELECT LOWER(HEX(id)) FROM product WHERE available = 0 AND id IN (:ids) AND product.version_id = :version',
['ids' => $bytes, 'version' => Uuid::fromHexToBytes($context->getVersionId())],
['ids' => Connection::PARAM_STR_ARRAY]
);
if (!empty($updated)) {
$this->dispatcher->dispatch(new ProductNoLongerAvailableEvent($updated, $context));
}
}
/**
* @param list<array{referenced_id: string, quantity: string}> $products
* @throws Exception
*/
private function updateStock(array $products, int $multiplier): void
{
$query = new RetryableQuery(
$this->connection,
$this->connection->prepare('UPDATE product SET stock = stock + :quantity WHERE id = :id AND version_id = :version')
);
foreach ($products as $product) {
/** @noinspection PhpDeprecationInspection */
$query->execute([
'quantity' => (int)$product['quantity'] * $multiplier,
'id' => Uuid::fromHexToBytes($product['referenced_id']),
'version' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
]);
}
}
/**
* @return array{lineItems: list<array{referenced_id: string, quantity: string}>, parentProductIds: list<string>, childProductIds: list<string>}
* @throws Exception|\Doctrine\DBAL\Driver\Exception
*/
private function getBundleProductsAndBundleChildProductsInOrder(string $orderId, Context $context): array
{
/** @noinspection DuplicatedCode */
$query = $this->connection->createQueryBuilder();
$query->select(['referenced_id', 'quantity']);
$query->from('order_line_item');
$query->andWhere('type = :type');
$query->andWhere('order_id = :id');
$query->andWhere('version_id = :version');
$query->setParameter('id', Uuid::fromHexToBytes($orderId));
$query->setParameter('version', Uuid::fromHexToBytes(Defaults::LIVE_VERSION));
$query->setParameter('type', LineItem::PRODUCT_LINE_ITEM_TYPE);
/** @var list<array{referenced_id: string, quantity: string}> $result */
$result = $query->executeQuery()->fetchAllAssociative();
$parentProductIds = $this->bundleFilter->filterParentProductIds(array_column($result, 'referenced_id'), $context);
$childProductIds = $this->bundleFilter->filterBundleChildrenIds(array_column($result, 'referenced_id'), $context);
$filteredIds = array_unique(array_merge($parentProductIds, $childProductIds));
return [
'lineItems' => array_filter($result, static fn(array $item) => in_array($item['referenced_id'], $filteredIds, true)),
'parentProductIds' => $parentProductIds,
'childProductIds' => $childProductIds,
];
}
/**
* @throws Exception
*/
public function update(array $ids, Context $context): void
{
$bundleParentIds = $this->bundleFilter->filterParentProductIds($ids, $context);
$bundleChildrenIds = $this->bundleFilter->filterBundleChildrenIds($ids, $context);
/*
* Bundle Children Available Stock Calculation
* ===========================================
* Available stock of bundle children is difference of stock and open quantity of bundle children from their direct orders
* and open quantities as part of open orders of bundle parents.
*
* available_stock = stock - open_quantity - open_quantity_in_bundles
*
* eg: Available stock calculation for product A which is part of Bundle 1 with quantity 2 and Bundle 2 with quantity 3
* - Product A has stock 100
* - Product A has open quantity 10 (direct product orders)
* - Bundle 1 has open quantity 5
* - Bundle 2 has open quantity 2
*
* Available stock of product A = 100 - 10 - (5 * 2) - (2 * 3) = 74
*
* Bundle Children Sales Calculation
* =================================
* Sales of bundle children is sum of sales from their direct completed orders and sales of bundle children as part of completed orders of their bundle parents.
*
* sales = sales_quantity + sales_quantity_in_bundles
*
* eg: Sales calculation for product A which is part of Bundle 1 with quantity 2 and Bundle 2 with quantity 3
* - Product A has sales 20 (direct product orders)
* - Bundle 1 has sales 10
* - Bundle 2 has sales 5
*
* Sales of product A = 20 + (10 * 2) + (5 * 3) = 50
*
*
* Bundle Parents Available Stock Calculation
* ==========================================
* Available stock of bundle parents is the minimum of available stock of bundle children divided by quantity of bundle children.
*
* available_stock = MIN(available_stock_of_bundle_children / quantity)
*
* eg: Bundle 1 has Product A with quantity 2 and Product B with quantity 3
* - Product A has available stock 74
* - Product B has available stock 60
* - Product A is available quantity for Bundle 1 = 74 / 2 = 37
* - Product B is available quantity for Bundle 1 = 60 / 3 = 20
*
* Available stock of Bundle 1 = MIN(37, 20) = 20
*
* Bundles Parents Availability And Close-out
* ==========================================
* If any child product is not available, then the parent product should not be available.
* If any child product is close-out, then the parent product should be close-out.
*
*/
/*
* For bundle parents, we don't calculate available stock and sales directly.
* We calculate available stock and sales of bundle children and then calculate available stock and sales of bundle parents.
*
* If any bundle child's available stock is updated, then we need to update the available stock of bundle parent.
*/
$bundleChildrenIdsFromUpdatedParentIds = $this->bundleFilter->findChildrenIds($bundleParentIds, $context);
$bundleParentIdsFromUpdatedChildrenIds = $this->bundleFilter->findParentIds($bundleChildrenIds, $context);
// We need to find the open and sale quantity of all children and their parents to calculate the available stock and sales of bundle children
$productIds = array_unique(array_merge($bundleParentIds, $bundleChildrenIds, $bundleChildrenIdsFromUpdatedParentIds, $bundleParentIdsFromUpdatedChildrenIds));
$openAndSaleQuantityOfProducts = $this->findOpenAndSaleQuantityOfProducts($productIds, $context);
// All the bundle children id's that are either directly updated or indirectly updated by parent product updates
$bundleChildrenIds = array_unique(array_merge($bundleChildrenIds, $bundleChildrenIdsFromUpdatedParentIds));
// We need to know the quantity of each child product in each bundle, finding them together in a single query is more efficient
$bundleQuantities = $this->bundleFilter->findBundleQuantities($bundleChildrenIds, $context);
// Update available stock and sales of bundle children
foreach ($bundleChildrenIds as $childId) {
$this->updateAvailableStockAndSalesOfBundleChild(
$childId,
$bundleQuantities[$childId],
$openAndSaleQuantityOfProducts
);
}
$this->updateAvailableFlag($bundleChildrenIds, $context);
// Update available stock and sales of bundle parents
$parentIds = $this->bundleFilter->findParentIds($bundleChildrenIds, $context);
$this->updateBundleStockAndAvailability($parentIds, $context);
$this->updateParentProductsSales($parentIds, $openAndSaleQuantityOfProducts);
}
/**
* @param array $bundleIds
* @param Context $context
* @return void
* @throws Exception
*/
public function updateBundleStockAndAvailability(array $bundleIds, Context $context): void
{
/*
* We need to know which all products are no longer after update, hence first we get currently available bundle products for given children.
* After update, we again get available bundle products, by comparing we can find which all bundle products are no longer available.
* We need to dispatch no longer available event for these products.
*/
$availableBundleProductsBeforeUpdate = $this->getCurrentlyAvailableBundleProducts($bundleIds);
$bundleAvailableStockUpdaterQuery = <<<SQL
UPDATE product
INNER JOIN
(SELECT all_stock.product_id,
# Close-out stock has higher priority than normal stock
COALESCE(close_out_stock.stock, all_stock.stock) AS stock,
COALESCE(close_out_stock.available_stock, all_stock.available_stock) AS available_stock,
# If any child product is close-out, then the parent product should be close-out
COALESCE(close_out_stock.is_close_out, 0) AS is_close_out
FROM
# From all child products of bundles find the available stock of bundle, we assume none of the child products are close-out
(SELECT parent_product.id AS product_id,
MIN((child_product.stock DIV wbfk_bundle_product.quantity)) AS stock,
MIN((child_product.available_stock DIV wbfk_bundle_product.quantity)) AS available_stock,
0 AS is_close_out
FROM wbfk_bundle_product
INNER JOIN product parent_product ON wbfk_bundle_product.product_id = parent_product.id
INNER JOIN product child_product ON wbfk_bundle_product.child_product_id = child_product.id
WHERE wbfk_bundle_product.product_id IN (:bundle_product_ids)
AND product_version_id = :version_id
GROUP BY parent_product.id) AS all_stock
LEFT JOIN
# From close-out child products of bundles find the available stock of bundle, available stock is minimum of all close-out child products
(SELECT parent_product.id AS product_id,
MIN((child_product.stock DIV wbfk_bundle_product.quantity)) AS stock,
MIN((child_product.available_stock DIV wbfk_bundle_product.quantity)) AS available_stock,
1 AS is_close_out
FROM wbfk_bundle_product
INNER JOIN product parent_product ON wbfk_bundle_product.product_id = parent_product.id
INNER JOIN product child_product ON wbfk_bundle_product.child_product_id = child_product.id
AND child_product.is_closeout = 1
WHERE wbfk_bundle_product.product_id IN (:bundle_product_ids)
AND product_version_id = :version_id
GROUP BY parent_product.id) AS close_out_stock
ON all_stock.product_id = close_out_stock.product_id) AS bundle_stock
ON product.id = bundle_stock.product_id
AND product.version_id = :version_id
SET product.stock = IF(bundle_stock.stock > 0, bundle_stock.stock, 0), # Stock of bundle parent cannot be negative
product.available_stock = IF(bundle_stock.available_stock > 0 , bundle_stock.available_stock, 0), # Available stock of bundle parent cannot be negative
product.is_closeout = bundle_stock.is_close_out
WHERE
product.stock != IF(bundle_stock.stock > 0, bundle_stock.stock, 0) OR
product.available_stock != IF(bundle_stock.available_stock > 0 , bundle_stock.available_stock, 0) OR
product.is_closeout != bundle_stock.is_close_out
SQL;
Profiler::trace('order::update-bundle-parent-stock', function () use ($bundleAvailableStockUpdaterQuery, $bundleIds): void {
$this->connection->executeStatement(
$bundleAvailableStockUpdaterQuery,
[
'bundle_product_ids' => Uuid::fromHexToBytesList($bundleIds),
'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
],
[
'bundle_product_ids' => Connection::PARAM_STR_ARRAY,
]
);
});
$availableBundleProductsAfterUpdate = $this->getCurrentlyAvailableBundleProducts($bundleIds);
$bundleProductsNoLongerAvailable = array_diff($availableBundleProductsBeforeUpdate, $availableBundleProductsAfterUpdate);
if (!empty($bundleProductsNoLongerAvailable)) {
$this->dispatcher->dispatch(new ProductNoLongerAvailableEvent($bundleProductsNoLongerAvailable, $context));
}
}
/**
* @throws Exception
*/
private function updateAvailableStockAndSalesOfBundleChild(string $bundleChildId, array $bundleQuantities, array $openAndSalesQuantitiesOfProducts): void
{
$openQuantity = $openAndSalesQuantitiesOfProducts[$bundleChildId]['open_quantity'] ?? 0;
$salesQuantity = $openAndSalesQuantitiesOfProducts[$bundleChildId]['sales_quantity'] ?? 0;
foreach ($bundleQuantities as $parentId => $quantity) {
$openAndSalesQuantityOfParent = $openAndSalesQuantitiesOfProducts[$parentId] ?? ['open_quantity' => 0, 'sales_quantity' => 0];
$openQuantity += $openAndSalesQuantityOfParent['open_quantity'] * $quantity;
$salesQuantity += $openAndSalesQuantityOfParent['sales_quantity'] * $quantity;
}
// Update available stock of child product
$update = new RetryableQuery(
$this->connection,
$this->connection->prepare('UPDATE product SET available_stock = stock - :open_quantity, sales = :sales_quantity, updated_at = :now WHERE id = :id')
);
/** @noinspection PhpDeprecationInspection */
$update->execute([
'id' => Uuid::fromHexToBytes($bundleChildId),
'open_quantity' => $openQuantity,
'sales_quantity' => $salesQuantity,
'now' => (new DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
]);
}
/**
* @throws Exception
*/
private function findOpenAndSaleQuantityOfProducts(array $ids, Context $context): array
{
if (empty($ids)) {
return [];
}
$sql = '
SELECT LOWER(HEX(order_line_item.product_id)) as product_id,
IFNULL(
SUM(IF(state_machine_state.technical_name = :completed_state, 0, order_line_item.quantity)),
0
) as open_quantity,
IFNULL(
SUM(IF(state_machine_state.technical_name = :completed_state, order_line_item.quantity, 0)),
0
) as sales_quantity
FROM order_line_item
INNER JOIN `order`
ON `order`.id = order_line_item.order_id
AND `order`.version_id = order_line_item.order_version_id
INNER JOIN state_machine_state
ON state_machine_state.id = `order`.state_id
AND state_machine_state.technical_name <> :cancelled_state
WHERE order_line_item.product_id IN (:ids)
AND order_line_item.type = :type
AND order_line_item.version_id = :version
AND order_line_item.product_id IS NOT NULL
GROUP BY product_id;
';
$rows = $this->connection->fetchAllAssociative(
$sql,
[
'type' => LineItem::PRODUCT_LINE_ITEM_TYPE,
'version' => Uuid::fromHexToBytes($context->getVersionId()),
'completed_state' => OrderStates::STATE_COMPLETED,
'cancelled_state' => OrderStates::STATE_CANCELLED,
'ids' => Uuid::fromHexToBytesList($ids),
],
[
'ids' => Connection::PARAM_STR_ARRAY,
]
);
// array key by product id
$openAndSalesQuantityOfProducts = [];
foreach ($rows as $row) {
$openAndSalesQuantityOfProducts[$row['product_id']] = [
'open_quantity' => $row['open_quantity'],
'sales_quantity' => $row['sales_quantity'],
];
}
return $openAndSalesQuantityOfProducts;
}
/**
* @throws Exception
*/
private function updateParentProductsSales(array $parentIds, array $openAndSaleQuantityOfProducts): void
{
$update = new RetryableQuery(
$this->connection,
$this->connection->prepare('UPDATE product SET sales = :sales_quantity, updated_at = :now WHERE id = :id')
);
foreach ($parentIds as $parentId) {
/** @noinspection PhpDeprecationInspection */
$update->execute([
'id' => Uuid::fromHexToBytes($parentId),
'sales_quantity' => $openAndSaleQuantityOfProducts[$parentId]['sales_quantity'] ?? 0,
'now' => (new DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
]);
}
}
private function getChildProductLineItemsFromParents($parentProductIds, $lineItems, $bundleChildrenQuantities): array
{
$childProductLineItemsFromParents = [];
foreach ($parentProductIds as $parentId) {
$childQuantities = $bundleChildrenQuantities[$parentId];
$parentQuantityInOrder = $lineItems[array_search($parentId, array_column($lineItems, 'referenced_id'), true)]['quantity'];
foreach ($childQuantities as $childId => $quantity) {
if (isset($childProductLineItemsFromParents[$childId])) {
$childProductLineItemsFromParents[$childId]['quantity'] += $quantity * $parentQuantityInOrder;
continue;
}
$childProductLineItemsFromParents[$childId] = [
'referenced_id' => $childId,
'quantity' => $quantity * $parentQuantityInOrder,
];
}
}
return $childProductLineItemsFromParents;
}
/**
* @param array $bundleIds
* @return string[]
* @throws Exception
*/
public function getCurrentlyAvailableBundleProducts(array $bundleIds): array
{
$availableBundleProductsQuery = <<<SQL
SELECT LOWER(HEX(product.id)) AS product_id FROM product
WHERE product.available = 1 AND
product.version_id = :version_id AND
id IN (:ids)
SQL;
return $this->connection->fetchFirstColumn(
$availableBundleProductsQuery,
[
'ids' => Uuid::fromHexToBytesList($bundleIds),
'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
],
[
'ids' => Connection::PARAM_STR_ARRAY,
]
);
}
}