<?php declare(strict_types=1);
namespace GlobusSW6\Service\StoreLocator;
use Doctrine\DBAL\Connection;
use GlobusSW6\Core\Content\Attributes\Product\ProductAttributesEntity;
use GlobusSW6\Core\IaneoDefaults;
use GlobusSW6\Service\App\AppService;
use GlobusSW6\Service\CheckoutVisibility\CheckoutVisibilityStruct;
use Psr\Log\LoggerInterface;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\JsonResponse;
use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Symfony\Component\HttpFoundation\Request;
use GlobusSW6\Storefront\Controller\IaneoCartLineItemControllerExtension;
use Shopware\Core\Content\Product\ProductEntity;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Shopware\Core\Framework\Adapter\Translation\Translator;
class StoreSwitchService
{
/** @var Translator */
protected $translator;
/** @var StoreLocatorService */
protected $storeService;
/** @var StockService */
protected $stockService;
/** @var Connection */
protected $connection;
/** @var EntityRepositoryInterface */
protected $productRepository;
/** @var IaneoCartLineItemControllerExtension */
private $cartLineItemController;
/** @var SessionInterface */
private $session;
/** @var CartService */
private $cartService;
/** @var LoggerInterface */
private $logger;
/** @var AppService */
private $appService;
const COOKIE_TIME = 360000;
/**
* @param Translator $translator
* @param StoreLocatorService $storeService
* @param StockService $stockService
* @param Connection $connection
* @param EntityRepositoryInterface $productRepository
* @param IaneoCartLineItemControllerExtension $cartLineItemController
* @param SessionInterface $session
* @param CartService $cartService
* @param LoggerInterface $logger
* @param AppService $appService
*/
public function __construct(Translator $translator, StoreLocatorService $storeService, StockService $stockService, Connection $connection, EntityRepositoryInterface $productRepository, IaneoCartLineItemControllerExtension $cartLineItemController, SessionInterface $session, CartService $cartService, LoggerInterface $logger, AppService $appService)
{
$this->translator = $translator;
$this->storeService = $storeService;
$this->stockService = $stockService;
$this->connection = $connection;
$this->productRepository = $productRepository;
$this->cartLineItemController = $cartLineItemController;
$this->session = $session;
$this->cartService = $cartService;
$this->logger = $logger;
$this->appService = $appService;
}
/**
* @param Cart $cart
* @param array $formattedStores
* @param string|null $salesChannelId
* @param string|null $languageId
* @return JsonResponse|null
*
* TODO-NGS: Description
*/
public function handleReservedItemsInCart(Cart $cart, array $formattedStores, ?string $salesChannelId = null, ?string $languageId = null, ?bool $enrichInformation = null )
{
$lineItems = $cart->getLineItems();
// detect lineItems in cart that are reserved in store
$reservedItems = $this->findReservationLineItems($lineItems);
if (count($reservedItems) !== 0) {
$storeStockArray = $this->storeService->getMaxPurchaseForCertainProductsInCertainStores(array_keys($reservedItems), $formattedStores);
// compares the max. available stock of a product in a store to the desired amount in cart
$availability = $this->compareCartQuantityToStoreMaxPurchase($reservedItems, $storeStockArray, $salesChannelId, $languageId);
// getProductNumber and Description
$additionalProductData = $this->addProductData($availability, $reservedItems, $enrichInformation);
return new JsonResponse([
'success' => true,
'total' => count($additionalProductData),
'data' => $this->storeService->indexAscending($additionalProductData)
]);
}
return null;
}
/**
* @param array $productIds
* @return array
*
* Used to get additional product information like product number or description.
* Also detects it the product can be ordered online.
* Adds this information to the availabilityArray in order to gather all information necessary for the switch-store modal
*/
private function addProductData(array $availabilityArray, array $reservedProductsInCart, ?bool $enrichInformation = null): array
{
$productIds = array_keys($reservedProductsInCart);
$criteria = new Criteria();
if ($enrichInformation) {
$criteria->addAssociation('ianeoAttributes');
$criteria->addAssociation('media');
}
$criteria->addFilter(new EqualsAnyFilter('id', $productIds));
$productCollection = $this->productRepository->search($criteria, Context::createDefaultContext());
foreach ($availabilityArray as $storeLegacyId => $storeEntry) {
foreach ($storeEntry['products'] as $productId => $productEntry) {
/** @var ProductEntity $product */
$product = $productCollection->get($productId);
if ($enrichInformation) {
$subtitle = null;
/** @var ProductAttributesEntity $ianeoAttributes */
$ianeoAttributes = $product->getExtensions()['ianeoAttributes'];
if (!is_null($ianeoAttributes)) {
$subtitle = $ianeoAttributes->getSubtitle();
}
try {
$mediaUrl = $product->getMedia()->first()->getMedia()->getUrl();
} catch (\Throwable $t) {
$mediaUrl = null;
}
$availabilityArray[$storeLegacyId]['products'][$productId]['mediaUrl'] = $mediaUrl;
$availabilityArray[$storeLegacyId]['products'][$productId]['subtitle'] = $subtitle;
}
$amountInCart = $reservedProductsInCart[$productId];
$maxOnlinePurchase = $this->stockService->getArticleSpecificMaxPurchaseById($productId);
$buyOnlineBtn = ($amountInCart <= $maxOnlinePurchase);
$availabilityArray[$storeLegacyId]['products'][$productId]['name'] = $product->getName();
$availabilityArray[$storeLegacyId]['products'][$productId]['description'] = $product->getDescription();
$availabilityArray[$storeLegacyId]['products'][$productId]['productNumber'] = $product->getProductNumber();
$availabilityArray[$storeLegacyId]['products'][$productId]['maxOnlinePurchase'] = $maxOnlinePurchase;
$availabilityArray[$storeLegacyId]['products'][$productId]['amountInCart'] = $amountInCart;
$availabilityArray[$storeLegacyId]['products'][$productId]['buyOnlineBtn'] = $buyOnlineBtn;
}
}
return $availabilityArray;
}
/**
* @param LineItemCollection $lineItems
* @return array
*
* Given the line items from the cart, this function searches for reservation items and returns them
*/
private function findReservationLineItems(LineItemCollection $lineItems)
{
$reservedItems = [];
/** @var LineItem $lineItem */
foreach ($lineItems as $lineItem) {
$extensions = $lineItem->getExtensions();
if (array_key_exists('ianeoOrderType', $extensions)) {
if ($extensions['ianeoOrderType']['orderType'] == 'reserveInStore' && $lineItem->getType() == 'product') {
// special case custprod / holzzuschnitt article -> here referencedId is normal productId
if (array_key_exists('ianeoAttributes', $extensions)
&& ($extensions['ianeoAttributes']->getCustprodParamsCount() > 0) )
{
$reservedItems[$lineItem->getReferencedId()] = $lineItem->getQuantity();
} else { // normal case
$reservedItems[$lineItem->getId()] = $lineItem->getQuantity();
}
}
}
}
return $reservedItems;
}
/**
* @param string $languageId
* @return false|mixed
* @throws \Doctrine\DBAL\Exception
*
* Helper function that collects the locale id required to configure the translator
*/
private function getLocale(string $languageId)
{
$localeId = $this->connection->fetchOne("
SELECT LOWER(HEX(locale_id))
FROM language l
WHERE l.id = :lId;
", ['lId' => Uuid::fromHexToBytes($languageId)]);
return $localeId;
}
/**
* @param array $cartArray
* Has the form:
* [
* 'd373db18a3134ab096f7d12ddc0fd56f' => 4, // productId
* ...
* ]
*
* @param array $storeStockArray
* Compare: StockService->maxPurchaseForReservationInStore(...) Has the form:
* [
* 0 => [
* "store" => "1155"
* "product_id" => 'd373db18a3134ab096f7d12ddc0fd56f'
* ...
* "maxStock" => 5
* ], ...
*
* '1155' => [ // storeLegacyId
* ...,
* "products" => [
* 'd373db18a3134ab096f7d12ddc0fd56f' => [ // productId
* 'maxStock' => 5
* ]
* ],
* ...
* ], ...
*
* ]
*
* Compares the desired stock to the available stock in store. The storeStockArray is enriched with
* availability information.
*
*/
private function compareCartQuantityToStoreMaxPurchase(array $cartArray, array $storeStockArray, ?string $salesChannelId, ?string $languageId)
{
$result = [];
$localId = null;
if (!is_null($languageId)) {
$localId = $this->getLocale($languageId);
}
if (!is_null($localId) && !is_null($languageId)) {
// allows to use snippets even if we are not in a storefront controller
$this->translator->injectSettings(
$salesChannelId,
$languageId,
$localId,
Context::createDefaultContext()
);
}
foreach ($storeStockArray as $storeLegacyId => $storeStockEntry) {
$availabilityCounter = 0;
foreach ($cartArray as $productId => $quantity) {
if (!array_key_exists($productId, $storeStockEntry['products'])) { // store does not contain product
$storeStockArray[$storeLegacyId]['products'][$productId] = [
'maxStock' => 0,
'availability' => false
];
continue;
}
if ($storeStockEntry['products'][$productId]['maxStock'] >= $quantity) { // available
$storeStockArray[$storeLegacyId]['products'][$productId]['availability'] = true;
$availabilityCounter++;
} else { // not available
$storeStockArray[$storeLegacyId]['products'][$productId]['availability'] = false;
}
}
// set overall reserved-product availability in this store: all available/none available/partially available
if ($availabilityCounter == count($cartArray)) {
$storeStockArray[$storeLegacyId]['availability'] = $this->translator->trans('store.product.available');
$storeStockArray[$storeLegacyId]['modal'] = false;
} else if ($availabilityCounter == 0) {
$storeStockArray[$storeLegacyId]['availability'] = $this->translator->trans('store.product.unavailable');
$storeStockArray[$storeLegacyId]['modal'] = true;
$storeStockArray[$storeLegacyId]['availableCount'] = $availabilityCounter;
$storeStockArray[$storeLegacyId]['totalCount'] = count($cartArray);
} else {
$storeStockArray[$storeLegacyId]['availability'] = $this->translator->trans('store.product.partiallyAvailable', ['%share%' => $availabilityCounter, '%total%' => count($cartArray)]);
$storeStockArray[$storeLegacyId]['modal'] = true;
$storeStockArray[$storeLegacyId]['availableCount'] = $availabilityCounter;
$storeStockArray[$storeLegacyId]['totalCount'] = count($cartArray);
}
}
return $storeStockArray;
}
// TODO-NGS: use CartService->getCart instead
public function getCart(string $token)
{
$content = $this->connection->fetchColumn(
'SELECT cart FROM cart WHERE token = :token',
['token' => $token]
);
/** @var Cart $cart */
$cart = unserialize((string)$content);
if (!$cart instanceof Cart) {
return null;
}
return $cart;
}
/**
* @param array $products
* @param string $storeId
* @param Cart $cart
* @param SalesChannelContext $salesChannelContext
*
* Change the 'store' in the lineItem extensions of 'products' to the id 'storeId' and update the cart accordingly
*/
public function changeStoreForLineItems(array $products, string $storeId, Cart $cart, SalesChannelContext $salesChannelContext)
{
if (count($products) === 0) {
return;
}
/** @var LineItem $lineItem */
foreach ($cart->getLineItems() as $lineItem) {
$extensions = $lineItem->getExtensions();
// Reservieren Artikel
if (array_key_exists('ianeoOrderType', $extensions) && $extensions['ianeoOrderType']['orderType'] === 'reserveInStore') {
if (in_array($lineItem->getId(), $products) || in_array($lineItem->getReferencedId(), $products)) {
$this->switchExtensions($lineItem, $storeId);
$modifiedLineItems [] = $lineItem;
// remove old lineItem from cart
$this->cartLineItemController->deleteLineItem($cart, $lineItem->getId(), new Request(), $salesChannelContext);
continue;
}
$this->logger->info( 'Problems to switch store for lineitem: ' . $lineItem->getId() . ' in cart ' . $cart->getToken());
}
}
// add line item with adjusted store
$this->cartLineItemController->addCertainLineItems($cart, $modifiedLineItems, new Request(), $salesChannelContext);
}
/**
* @param array $products
* @param Cart $cart
* @param SalesChannelContext $salesChannelContext
*
* Remove line items specified in products-array
*/
public function removeLineItems(array $products, Cart $cart, SalesChannelContext $salesChannelContext)
{
foreach ($products as $productId) {
// remove old lineItem from cart
$this->cartLineItemController->deleteLineItem($cart, $productId, new Request(), $salesChannelContext);
}
}
/**
* @param LineItem $lineItem
* @param string $storeId
*
* If an item is reserved in another store and the quantity does not change,
* it is sufficient to exchange the storeId in the lineItem extensions.
* Also, the acidSplitCartCheckoutIaneoData extension is removed in case it contains outdated information.
*/
private function switchExtensions(LineItem $lineItem, string $storeId)
{
$extensions = $lineItem->getExtensions();
if (!array_key_exists('ianeoOrderType', $extensions) && !array_key_exists('store', $extensions['ianeoOrderType'])) {
throw new \InvalidArgumentException('Expected line item for reservation in store');
}
$extensions['ianeoOrderType']['store'] = (int)$storeId;
if (array_key_exists('acidSplitCartCheckoutIaneoData', $extensions)) {
unset($extensions['acidSplitCartCheckoutIaneoData']);
}
$lineItem->setExtensions($extensions);
}
public function getProductIdByNumber(string $productNumber)
{
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('productNumber', $productNumber));
/** @var ProductEntity $product */
$product = $this->productRepository->search($criteria, Context::createDefaultContext())->first();
if (is_null($product)) {
return null;
} else {
return $product->getId();
}
}
public function setStoreInSession(Context $context, string $storeId): array
{
$store = $this->storeService->getStore($storeId, $context);
if (is_null($store)) {
throw new \InvalidArgumentException('Store ' . $storeId . ' not found');
}
$storeFormatted = $this->storeService->formatStore([$store]);
$this->session->set('ianeoCurrentStore', $store->getLegacyId());
$this->session->set('isFallbackStore', false);
// TODO-NGS: Only fallback! Cookie validness could become a problem.
// Only use this if session does not have value. E.g. session->getVariable = -1 (in case session died for some reason)
setcookie('ianeoCurrentStore', (string)$store->getLegacyId(), time() + self::COOKIE_TIME, '/');
return $storeFormatted;
}
public function setStoreInSessionWithFallbackStoreFlag(Context $context, string $storeId, bool $setFallback): array
{
$store = $this->storeService->getStore($storeId, $context);
if (is_null($store)) {
throw new \InvalidArgumentException('Store ' . $storeId . ' not found');
}
$storeFormatted = $this->storeService->formatStore([$store]);
$this->session->set('ianeoCurrentStore', $store->getLegacyId());
$this->session->set('isFallbackStore', $setFallback);
// TODO-NGS: Only fallback! Cookie validness could become a problem.
// Only use this if session does not have value. E.g. session->getVariable = -1 (in case session died for some reason)
setcookie('ianeoCurrentStore', (string)$store->getLegacyId(), time() + self::COOKIE_TIME, '/');
return $storeFormatted;
}
public function changeStoreAndAdjustCart(string $storeId, Cart $cart, SalesChannelContext $salesChannelContext)
{
// test if cart contains products that were reserved in a different store
$reserveProducts = [];
$removeProducts = [];
$store = $this->storeService->getStore($storeId, Context::createDefaultContext());
$formattedStore = $this->storeService->formatStore([$store]);
/** @var JsonResponse $availabilityInformation */
$availabilityInformation = $this->handleReservedItemsInCart($cart, $formattedStore);
if (!empty($availabilityInformation)) {
$products = json_decode($availabilityInformation->getContent(), true)['data'][0]['products'];
foreach ($products as $key => $product) {
$product['availability'] == true ? $reserveProducts [] = $key : $removeProducts [] = $key;
}
}
$this->changeStoreForLineItems($reserveProducts, $storeId, $cart, $salesChannelContext);
$this->removeLineItems($removeProducts, $cart, $salesChannelContext);
$cartMessage = "";
$reservedNumber = count($reserveProducts);
$removedNumber = count($removeProducts);
$token = $this->session->get('sw-context-token');
$this->setStoreInSalesChannelApiPayload($token, (int)$storeId);
if ($this->appService->isAppFromSession($this->session)) {
$this->appService->saveStoreForDevice($token, (int)$storeId, $this->session->get('x-globus-device-id'));
}
if ($reservedNumber === 0 && $removedNumber === 0) { // no change in cart
$cartMessage = "";
} else if ($reservedNumber >= 0 && $removedNumber === 0) { // all reservation items were reserved in newly selected store
$cartMessage = $this->translator->trans('switchStore.switchedAll');
} else if ($reservedNumber === 0 && $removedNumber >= 0) { // all reservation items were removed
$cartMessage = $this->translator->trans('switchStore.removedAll');
} else { // Some reservation items were removed, others were reserved in newly selected store
$cartMessage = $this->translator->trans('switchStore.switchedPartially');
}
return new JsonResponse([
'success' => true,
'data' => $formattedStore,
'cartMessage' => $cartMessage
]);
}
/**
* @param string $contextToken
* @param SalesChannelContext $salesChannelContext
*
* Is called if no store is set for session, as we always need a store set.
* If the cart associated with the session does not contain reservation line items, the fallback store is selected.
* If there are reservation line items contained, the function checks which store was used for these items and selects this store for the session.
* If the cart contains reservation line items from multiple stores, the reservation line items are removed from the cart and the fallback store
* is selected, as we only allow reservations for one store at a time.
*/
public function setFallbackStore(Session $session, SalesChannelContext $salesChannelContext): void
{
$salesChannelId = !empty($salesChannelContext->getSalesChannelId()) ? $salesChannelContext->getSalesChannelId() : IaneoDefaults::SALES_CHANNEL_BAUMARKT;
$contextToken = $session->get('sw-context-token');
$cart = $this->cartService->getCart($contextToken, $salesChannelContext);
if (is_null($cart)) {
if($salesChannelId !== IaneoDefaults::SALES_CHANNEL_ALPHATECC){
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '62',true); // TODO-NGS: remove hardcoded fallback store ID
} else {
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '305', true);
}
return;
}
$lineItems = $cart->getLineItems();
if (is_null($lineItems) || count($lineItems) === 0) {
if($salesChannelId !== IaneoDefaults::SALES_CHANNEL_ALPHATECC){
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '62',true); // TODO-NGS: remove hardcoded fallback store ID
} else {
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '305', true);
}
return;
}
$reservationLIs = $this->findReservationLineItems($lineItems);
if (count($reservationLIs) === 0) {
if($salesChannelId !== IaneoDefaults::SALES_CHANNEL_ALPHATECC){
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '62', true); // TODO-NGS: remove hardcoded fallback store ID
} else {
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '305', true);
}
return;
}
// check which stores are in cart
$stores = $this->getStores($reservationLIs, $lineItems);
if (count($stores) !== 1) {
$this->logger->error(__CLASS__ . ":" . __FUNCTION__ . ":" . __LINE__ . ": Cart with token " . $contextToken . " contains " . count($stores) . " stores");
// remove reservation lineItems in this case
foreach ($reservationLIs as $id => $quantity) {
$this->cartLineItemController->deleteLineItem($cart, $id, new Request(), $salesChannelContext);
}
if($salesChannelId !== IaneoDefaults::SALES_CHANNEL_ALPHATECC){
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '62', true); // TODO-NGS: remove hardcoded fallback store ID
} else {
$this->setStoreInSessionWithFallbackStoreFlag(Context::createDefaultContext(), '305', true);
}
return;
}
// set store to store already used in cart
$fallbackStore = $stores[0];
$this->changeStoreAndAdjustCart($fallbackStore, $cart, $salesChannelContext);
$session->set('isFallbackStore', false);
}
/**
* @param array $reservationLIs
* @param LineItemCollection $cartLineItems
* @return array
*
* Get all stores from reservation lineItems $reservationLIs in the cart lineItems $cartLineItems.
* Note: We do not want to mix reservations from different stores, so usually this method should return an array of length 1.
* This method can be used to verify if the cart contains a single store as expected
*/
private function getStores(array $reservationLIs, LineItemCollection $cartLineItems)
{
$storeArray = []; // only expect one store
foreach ($reservationLIs as $key => $quantity) {
/** @var LineItem $lineItem */
$lineItem = $cartLineItems->get($key);
$lineItem->getExtension('orderType');
$extensions = $lineItem->getExtensions();
if (array_key_exists('store', $extensions)) {
$storeArray = $extensions['store'];
}
}
return array_unique($storeArray);
}
private function setStoreInSalesChannelApiPayload(string $token, int $storeLegacyId)
{
$payload = $this->connection->fetchAssoc('
SELECT payload
FROM sales_channel_api_context scac
WHERE scac.token = :token;
', [
'token' => $token
]);
if (!empty($payload)) {
// TODO-NGS: Is there a more efficient way to do this?
$payload = json_decode($payload['payload'], true);
$payload['ianeoCurrentStore'] = $storeLegacyId;
$payload = json_encode($payload);
$this->connection->update('sales_channel_api_context', ['payload' => $payload], ['token' => $token]);
}
}
public function getSalesChannelId(string $token)
{
$salesChannelId = $this->connection->fetchOne(
'SELECT LOWER(HEX(sales_channel_id))FROM cart WHERE token = :token',
['token' => $token]
);
return $salesChannelId;
}
}