305 lines
10 KiB
PHP
305 lines
10 KiB
PHP
<?php
|
|
|
|
namespace KupShop\GraphQLBundle\ApiPublic\Util;
|
|
|
|
use KupShop\CatalogBundle\ProductList\ProductList;
|
|
use KupShop\CatalogBundle\Util\Product\ProductDiscountCalculator;
|
|
use KupShop\ContentBundle\Exception\InvalidCartItemException;
|
|
use KupShop\GraphQLBundle\ApiPublic\Types\Cart\CartItemInput;
|
|
use KupShop\GraphQLBundle\ApiShared\ApiUtil;
|
|
use KupShop\GraphQLBundle\ApiShared\Util\ProductListUtil;
|
|
use KupShop\GraphQLBundle\Exception\CartPreconditionFailedException;
|
|
use KupShop\KupShopBundle\Util\Database\QueryHint;
|
|
use KupShop\OrderingBundle\Cart;
|
|
use KupShop\OrderingBundle\Util\Purchase\PurchaseUtil;
|
|
use Query\Operator;
|
|
use Symfony\Contracts\Service\Attribute\Required;
|
|
|
|
class CartUtil
|
|
{
|
|
protected $cart;
|
|
|
|
protected $purchaseUtil;
|
|
|
|
protected $productListUtil;
|
|
|
|
protected $productList;
|
|
|
|
#[Required]
|
|
public ProductDiscountCalculator $productDiscountCalculator;
|
|
|
|
public function __construct(Cart $cart, PurchaseUtil $purchaseUtil, ProductListUtil $productListUtil, ProductList $productList)
|
|
{
|
|
$this->cart = $cart;
|
|
$this->purchaseUtil = $purchaseUtil;
|
|
$this->productListUtil = $productListUtil;
|
|
$this->productList = $productList;
|
|
}
|
|
|
|
public function getCart(bool $invalidatePurchaseState = false): \KupShop\GraphQLBundle\ApiPublic\Types\Cart\Cart
|
|
{
|
|
if ($invalidatePurchaseState) {
|
|
$this->cart->invalidatePurchaseState();
|
|
}
|
|
|
|
$this->purchaseUtil->getCart()->load();
|
|
$purchaseState = $this->purchaseUtil->getPurchaseState();
|
|
|
|
if (!empty($purchaseState->getProducts())) {
|
|
$collection = $purchaseState->createProductCollection();
|
|
|
|
$this->productDiscountCalculator->prefetchPriceTypes($collection);
|
|
}
|
|
|
|
return new \KupShop\GraphQLBundle\ApiPublic\Types\Cart\Cart(
|
|
$this->purchaseUtil, $purchaseState
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param CartItemInput[] $items
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function updateCart(array $items, ?\DateTimeInterface $lastUpdated): void
|
|
{
|
|
$actualItems = QueryHint::withRouteToMaster(fn () => sqlFetchAll(
|
|
sqlQueryBuilder()->select('id id_cart, id_product, id_variation, note, pieces, date')->from('cart')
|
|
->where(Operator::equals($this->cart->getSelectParams()))
|
|
->execute(),
|
|
'id_cart'));
|
|
|
|
if ($lastUpdated && $this->hasCartChangedSince($lastUpdated, $actualItems, $items)) {
|
|
throw new CartPreconditionFailedException();
|
|
}
|
|
|
|
$this->filterOutInvalidItems($actualItems, $items);
|
|
|
|
$newItems = [];
|
|
foreach ($items as $item) {
|
|
$id = $item->getCartId();
|
|
$newNote = $item->getNote();
|
|
$newItems[] = [
|
|
'id_cart' => $item->getCartId(),
|
|
'id_product' => $item->getProductId(),
|
|
'id_variation' => $item->getVariationId(),
|
|
'pieces' => $item->getPieces(),
|
|
'note' => ($actualItems[$id] ?? false) ? $actualItems[$id]['note'] : ($newNote ? json_encode($newNote) : ''),
|
|
];
|
|
}
|
|
|
|
$filteredItems = $this->filterItemsForUpdate($actualItems, $newItems);
|
|
|
|
foreach ($filteredItems as $filteredItem) {
|
|
$item = $filteredItem['new'];
|
|
$update = [
|
|
'id_cart' => $item['id_cart'],
|
|
'id_product' => $item['id_product'],
|
|
'id_variation' => $item['id_variation'],
|
|
'pieces' => $item['pieces'] - ($filteredItem['actual']['pieces'] ?? 0),
|
|
'note' => empty($item['note']) ? '' : json_decode($item['note'], true),
|
|
];
|
|
|
|
try {
|
|
$this->cart->addItem($update);
|
|
} catch (InvalidCartItemException $e) {
|
|
// the product does not exist. It could be in the cart, but has been deleted by the administrator in the meantime
|
|
}
|
|
}
|
|
|
|
$this->cart->invalidatePurchaseState();
|
|
}
|
|
|
|
protected function filterOutInvalidItems(array &$oldItems, array &$newItems): void
|
|
{
|
|
$newItemsToClear = [];
|
|
|
|
$oldItems = array_filter($oldItems, function ($item) use (&$newItemsToClear) {
|
|
// Remove items that have negative amount or have invalid JSON in note.
|
|
if (
|
|
$item['pieces'] < 0
|
|
|| ($item['note'] && json_decode($item['note']) === null)
|
|
) {
|
|
sqlQueryBuilder()->delete('cart')->where(Operator::equals(['id' => $item['id_cart']]))->execute();
|
|
$newItemsToClear[] = $item['id_cart'];
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
$newItems = array_filter($newItems, function ($item) use ($newItemsToClear) {
|
|
return !$item->getCartId() || !in_array($item->getCartId(), $newItemsToClear);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Finds differences between $actualItems and $newItems. Tries to match items by cart_id first, the rest is matched by id_p, id_v, note.
|
|
* Ensures that items are matched even if $newItems are supplied with existing items without cart_id. So duplicate rows cannot be created into the cart table.
|
|
*/
|
|
protected function filterItemsForUpdate($actualItems, $newItems): array
|
|
{
|
|
$getKey = function ($item) {
|
|
$idP = $item['id_product'];
|
|
$idV = $item['id_variation'] ?? 'empty';
|
|
$note = $item['note'] ?: 'empty';
|
|
|
|
return $idP.'-'.($idV ?? '').'-'.(md5($note) ?? '');
|
|
};
|
|
|
|
$newItemsIndex = [];
|
|
foreach ($newItems as $newItem) { // deduplicate new items and create index
|
|
$key = $getKey($newItem);
|
|
|
|
if ($newItemsIndex[$key] ?? false) {
|
|
$newItemsIndex[$key]['pieces'] += $newItem['pieces'] ?? 0;
|
|
if ($newItemsIndex[$key]['id_cart'] ?? false) {
|
|
$newItemsIndex[$key]['id_cart'] = $newItem['id_cart'];
|
|
}
|
|
} else {
|
|
$newItemsIndex[$key] = $newItem;
|
|
}
|
|
}
|
|
unset($newItems);
|
|
|
|
$differences = [];
|
|
|
|
// phase 1 - match items by cart_id and generate differences
|
|
foreach ($newItemsIndex as $nKey => $newItem) {
|
|
$actualItem = $actualItems[$newItem['id_cart']] ?? false;
|
|
if (empty($actualItem)) {
|
|
continue;
|
|
}
|
|
|
|
if (abs($actualItem['pieces'] - $newItem['pieces']) > PHP_FLOAT_EPSILON) {
|
|
$differences[] = ['new' => $newItem, 'actual' => $actualItem];
|
|
}
|
|
|
|
$actualItems[$newItem['id_cart']] = false;
|
|
$newItemsIndex[$nKey] = false;
|
|
}
|
|
|
|
$actualItems = array_filter($actualItems);
|
|
$newItemsIndex = array_filter($newItemsIndex);
|
|
|
|
// phase 2 - try to match remaining items by id_p, id_v, note
|
|
$actualItemsIndex = [];
|
|
foreach ($actualItems as $actualItem) {
|
|
$key = $getKey($actualItem);
|
|
$actualItemsIndex[$key] = $actualItem;
|
|
}
|
|
unset($actualItems);
|
|
|
|
foreach ($newItemsIndex as $key => $newItem) {
|
|
$actualItem = $actualItemsIndex[$key] ?? false;
|
|
|
|
if (!$actualItem) {
|
|
$differences[] = ['new' => $newItem];
|
|
continue;
|
|
}
|
|
|
|
unset($actualItemsIndex[$key]);
|
|
|
|
if (abs($actualItem['pieces'] - $newItem['pieces']) < PHP_FLOAT_EPSILON) {
|
|
continue;
|
|
}
|
|
$differences[] = ['new' => $newItem, 'actual' => $actualItem];
|
|
}
|
|
|
|
$deletedItems = array_map(function ($item) {
|
|
$item['pieces'] = -$item['pieces'];
|
|
|
|
return ['new' => $item];
|
|
}, $actualItemsIndex);
|
|
|
|
return array_merge(array_values($differences), array_values($deletedItems));
|
|
}
|
|
|
|
public function getProductListCopy($order, $limit)
|
|
{
|
|
$productList = clone $this->productList;
|
|
$productList->limit($limit);
|
|
$productList->andSpec(function ($qb) use ($order) {
|
|
$qb->orderBySql($order);
|
|
});
|
|
|
|
return $productList;
|
|
}
|
|
|
|
public function getAlsoBought($limit = 7)
|
|
{
|
|
$products = $this->purchaseUtil->getPurchaseState()->getProducts();
|
|
|
|
$productsIds = array_values(array_map(function ($prod) {
|
|
return ['id' => $prod->getIdProduct()];
|
|
}, $products));
|
|
|
|
$fallback = array_merge(empty($productsIds) ? [] : [['type' => 'related_product', 'products' => $productsIds, 'in_store' => '1']],
|
|
[['type' => 'bestselling_product', 'price_max' => 1200, 'order' => 'RAND()', 'in_store' => '1']]);
|
|
|
|
$products = (new \InsertProductsTypes())->getProducts(['type' => 'cart_product',
|
|
'count' => $limit,
|
|
'products' => $productsIds,
|
|
'image' => 2,
|
|
'in_store' => '1',
|
|
'fallback' => $fallback, ]);
|
|
|
|
return $products;
|
|
}
|
|
|
|
public function addCoupon(string $coupon): bool
|
|
{
|
|
$this->cart->load(['coupons']);
|
|
if ($this->cart->addCoupon($coupon)) {
|
|
$this->cart->save(['coupons']);
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function invalidatePurchaseState(): void
|
|
{
|
|
$this->cart->invalidatePurchaseState();
|
|
}
|
|
|
|
/**
|
|
* @param CartItemInput[] $newItems
|
|
*/
|
|
protected function hasCartChangedSince(\DateTimeInterface $lastUpdated, array $actualItems, array $newItems): bool
|
|
{
|
|
if (empty($actualItems)) {
|
|
return false;
|
|
}
|
|
|
|
$actualIds = array_map(fn ($item) => $item['id_cart'], $actualItems);
|
|
$newIds = array_filter(array_map(fn ($item) => $item->getCartId(), $newItems));
|
|
|
|
foreach ($newIds as $newId) {
|
|
if (!in_array($newId, $actualIds)) {
|
|
return true; // cart item was deleted in the meantime
|
|
}
|
|
}
|
|
|
|
$lastUpdatedCart = array_reduce($actualItems, function (?\DateTimeImmutable $gDate, $item) {
|
|
if (empty($item['date'])) {
|
|
return $gDate ?? null;
|
|
}
|
|
$itemDate = ApiUtil::prepareDateTimeFromDB($item['date']);
|
|
if ($gDate === null) {
|
|
return $itemDate;
|
|
}
|
|
|
|
return $gDate > $itemDate ? $gDate : $itemDate;
|
|
});
|
|
|
|
if (empty($lastUpdatedCart)) {
|
|
return false;
|
|
}
|
|
|
|
return $lastUpdatedCart > $lastUpdated; // somebody modified some cart item in the meantime
|
|
}
|
|
}
|