Files
2025-08-02 16:30:27 +02:00

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
}
}