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

434 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace KupShop\PreordersBundle\Service;
use Doctrine\DBAL\Exception;
use KupShop\CatalogBundle\ProductList\ProductCollection;
use KupShop\CatalogBundle\ProductList\ProductList;
use KupShop\I18nBundle\Entity\Currency;
use KupShop\I18nBundle\Util\PriceConverter;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\Price\Price;
use KupShop\KupShopBundle\Util\Price\PriceCalculator;
use KupShop\KupShopBundle\Util\Price\ProductPrice;
use KupShop\KupShopBundle\Util\Price\TotalPrice;
use KupShop\KupShopBundle\Wrapper\PriceWrapper;
use KupShop\OrderingBundle\Entity\Purchase\ProductPurchaseItem;
use KupShop\OrderingBundle\Entity\Purchase\PurchaseState;
use KupShop\OrderingBundle\Util\Purchase\PurchaseUtil;
use KupShop\PreordersBundle\Exception\UnsupportedOperationException;
use KupShop\PreordersBundle\Translations\PreordersDatesTranslation;
use KupShop\PreordersBundle\Translations\PreordersTranslation;
use Query\Operator;
use Query\Operator as Op;
use Query\Product;
use Query\QueryBuilder;
use Query\Translation;
class PreorderUtil
{
public const PREORDER_BASE_PRICE_FIELD = 'preorder_base_price_field';
public function __construct(
private readonly CurrencyContext $currencyContext,
private readonly PurchaseUtil $purchaseUtil,
private readonly PriceConverter $priceConverter,
) {
}
public function getPreorderProductList(?array $items = null, bool $variationsAsResult = true): ProductList
{
$productList = (new ProductList())
->fetchVariations(true, false)
->setVariationsAsResult($variationsAsResult);
if ($items !== null) {
// filter out unwanted products
$productList->andSpec(self::specSelectProductAndVariationIds($items));
// inject pre-ordered pieces into Product objects
$productList->addResultModifiers(self::modifierInjectFields($items, ['pieces' => 'pieces']));
// inject piece_price from DB -> used when creating an order - keep the same price
// when the item was initially preordered
$productList->addResultModifiers($this->modifierInjectPiecePrices($items, $variationsAsResult));
}
return $productList;
}
public function modifierProductPriceToActiveCurrency(): callable
{
$activeCurrency = Contexts::get(CurrencyContext::class)->getActive();
return function (ProductCollection $products) use ($activeCurrency) {
foreach ($products as $product) {
$productPrice = $product->getProductPrice();
if ($productPrice->getCurrency()->getId() === $activeCurrency->getId()) {
continue;
}
$convertedPrice = $this->priceConverter->convertPrice(
fromCurrency: $productPrice->getCurrency(),
toCurrency: $activeCurrency,
price: $productPrice->getValue(),
);
$product->setProductPrice(new ProductPrice($convertedPrice, $activeCurrency, $productPrice->getVat()));
}
};
}
public function modifierInjectPiecePrices(
array $itemsWithPiecePrices,
bool $variationsAsResult = true,
string $inKey = 'piece_price',
string $outKey = 'preorder_piece_price',
): callable {
if ($variationsAsResult) {
return function (ProductCollection $products) use ($itemsWithPiecePrices, $inKey, $outKey) {
foreach ($itemsWithPiecePrices as $itemWithPrice) {
if (empty($itemWithPrice[$inKey])) {
continue;
}
if (!empty($itemWithPrice['id_variation'])) {
$product = $products[$itemWithPrice['id_product'].'/'.$itemWithPrice['id_variation']];
} else {
$product = $products[$itemWithPrice['id_product']] ?? null;
if (!$product) {
foreach ($products as $maybeProduct) {
if ($maybeProduct->id === $itemWithPrice['id_product']) {
$product = $maybeProduct;
break;
}
}
}
}
if (!$product) {
continue;
}
$price = $product->getProductPrice();
$currency = $this->getCurrency($itemWithPrice['currency'] ?? null);
$product[$outKey] = new Price(
toDecimal($itemWithPrice[$inKey]),
$currency ?? $price->getCurrency(),
$price->getVat(),
);
}
};
}
return function (ProductCollection $products) use ($itemsWithPiecePrices, $inKey, $outKey) {
foreach ($itemsWithPiecePrices as $item) {
if (empty($item[$inKey])) {
continue;
}
if (!empty($item['id_variation'])) {
$price = $products[$item['id_product']]->variations[$item['id_variation']]['productPrice'];
$currency = $this->getCurrency($item['currency'] ?? null);
$products[$item['id_product']]->variations[$item['id_variation']][$outKey] = new Price(
toDecimal($item[$inKey]),
$currency ?? $price->getCurrency(),
$price->getVat(),
);
} else {
$price = $products[$item['id_product']]->getProductPrice();
$currency = $this->getCurrency($item['currency'] ?? null);
$products[$item['id_product']][$outKey] = new Price(
toDecimal($item[$inKey]),
$currency ?? $price->getCurrency(),
$price->getVat(),
);
}
}
};
}
private function getCurrency(?string $id): ?Currency
{
$currencies = $this->currencyContext->getAll();
if ($id && isset($currencies[$id])) {
return $currencies[$id];
}
return null;
}
public function calculateBasePrice(ProductCollection $products): TotalPrice
{
$purchaseState = self::toPurchaseState($products, self::PREORDER_BASE_PRICE_FIELD);
$this->purchaseUtil->recalculateTotalPrices($purchaseState);
return $purchaseState->getTotalPrice();
}
public static function toPurchaseState(ProductCollection $products, ?string $customPriceField = null): PurchaseState
{
$purchaseItems = [];
/** @var \Product $product */
foreach ($products as $product) {
if ($product instanceof \Variation || empty($product->variations)) {
if (empty($product['pieces'])) {
continue;
}
$price = match ($customPriceField) {
null => $product->getProductPrice(),
self::PREORDER_BASE_PRICE_FIELD => $product['dynamic_prices'][0]['price'],
default => $product[$customPriceField] ?? null,
};
if (null === $price) {
throw new UnsupportedOperationException('Custom price field is empty!');
}
if ($price instanceof PriceWrapper) {
$price = $price->getObject();
}
if ($price instanceof ProductPrice && !empty($price->getDiscount())) {
$price = PriceCalculator::addDiscount($price, toDecimal($price->getDiscount()));
}
$purchaseItems[] = new ProductPurchaseItem(
$product->id,
($product instanceof \Variation)
? $product->variationId : null,
(int) $product['pieces'],
PriceCalculator::mul($price, toDecimal($product['pieces'])),
null
);
continue;
}
foreach ($product->variations as $variation) {
if (empty($variation['pieces'])) {
continue;
}
$price = match ($customPriceField) {
null => $variation['productPrice'],
self::PREORDER_BASE_PRICE_FIELD => $variation['dynamic_prices'][0]['price'],
default => $variation[$customPriceField] ?? null,
};
if (null === $price) {
throw new UnsupportedOperationException('Custom price field is empty!');
}
if ($price instanceof PriceWrapper) {
$price = $price->getObject();
}
$purchaseItems[] = new ProductPurchaseItem(
$product->id,
$variation['id'],
(int) $variation['pieces'],
PriceCalculator::mul($price, toDecimal($variation['pieces'])),
null
);
}
}
return new PurchaseState($purchaseItems);
}
public static function injectFields(ProductCollection $products, array $itemsWithValues, array $fieldMapping = []): void
{
foreach ($itemsWithValues as $item) {
$toInject = [];
foreach ($fieldMapping as $formerKey => $newKey) {
$toInject[$newKey] = $item[$formerKey];
}
$itemIsVariation = isset($item['id_variation']);
$isClassVariationInstance = $itemIsVariation
&& isset($products[$item['id_product'].'/'.$item['id_variation']]);
if ($isClassVariationInstance) {
$object = $products[$item['id_product'].'/'.$item['id_variation']];
foreach ($toInject as $key => $val) {
$object[$key] = $val;
}
continue;
}
if (!isset($products[$item['id_product']])) {
// Product now has variations, but was saved without -> try to find it...
$product = null;
foreach ($products as $maybeProduct) {
if ($maybeProduct->id === (int) $item['id_product']) {
$product = $maybeProduct;
break;
}
}
if ($product) {
foreach ($toInject as $key => $value) {
$product[$key] = $value;
}
}
continue;
}
if ($itemIsVariation) {
foreach ($toInject as $key => $value) {
$products[$item['id_product']]->variations[$item['id_variation']][$key] = $value;
}
continue;
}
// item is a product without variations
foreach ($toInject as $key => $val) {
$products[$item['id_product']][$key] = $val;
}
}
}
public static function modifierInjectFields(array $itemsWithValues, array $fieldMapping = []): callable
{
return function (ProductCollection $products) use ($itemsWithValues, $fieldMapping) {
self::injectFields($products, $itemsWithValues, $fieldMapping);
};
}
public static function specSelectProductAndVariationIds(array $items): callable
{
$ids = [];
foreach ($items as $item) {
if (!array_key_exists($item['id_product'], $ids)) {
$ids[$item['id_product']] = [];
}
if (!empty($item['id_variation'])) {
$ids[$item['id_product']][] = $item['id_variation'];
}
}
foreach ($ids as $productId => $variationId) {
if (empty($variationId)) {
$ids[$productId] = null;
}
}
return Product::productsAndVariationsIds($ids);
}
public static function valueIsComposedOfDigits($value): bool
{
return is_int($value) || (is_string($value) && ctype_digit($value));
}
public static function fieldIsComposedOfDigits($array, $key): bool
{
if (!is_array($array) && !($array instanceof \ArrayAccess)) {
return false;
}
return isset($array[$key]) && self::valueIsComposedOfDigits($array[$key]);
}
public static function specDateIsOpen(): callable
{
return function (QueryBuilder $qb) {
$qb->andWhere('pd.date_start <= NOW()')
->andWhere('pd.date_end >= CURDATE()');
};
}
public static function useMultipleDates(): bool
{
$dbcfg = \Settings::getDefault();
return ($dbcfg->preorders['multiple_dates'] ?? 'N') === 'Y';
}
/**
* @throws Exception
* @throws \Doctrine\DBAL\Driver\Exception
*/
public static function getOpenDates(?\User $user, ?array $preorderIds = null): array
{
$preorders = sqlQueryBuilder()
->select('po.*')
->from('preorders', 'po')
->andWhere(Translation::coalesceTranslatedFields(PreordersTranslation::class, ['name' => 'preorder_name']));
$datesQb = sqlQueryBuilder()
->select('pd.*', 'po.*', 'pd.id AS id', 'po.preorder_name AS name')
->from('preorders_dates', 'pd')
->leftJoinSubQuery('pd', $preorders, 'po', 'pd.id_preorder = po.id')
->andWhere(PreorderUtil::specDateIsOpen())
->andWhere(Translation::coalesceTranslatedFields(PreordersDatesTranslation::class))
->orderBy('pd.date_end', 'ASC');
if ($preorderIds !== null) {
$datesQb->andWhere(Op::inIntArray($preorderIds, 'pd.id_preorder'));
}
$dates = $datesQb->execute()->fetchAllAssociative();
if ($user) {
foreach ($dates as $key => &$date) {
if (is_string($date['settings'])) {
$date['settings'] = json_decode($date['settings'], true);
}
$groups = $user->getGroups();
$hasGroup = false;
foreach (($date['settings']['users_groups'] ?? []) as $group) {
if (array_key_exists((int) $group, $groups)) {
$hasGroup = true;
break;
}
}
if (!$hasGroup) {
unset($dates[$key]);
}
}
}
return array_values($dates);
}
public static function countUserPreorders(\User|int $user, ?callable $spec = null): int
{
if ($user instanceof \User) {
$user = $user->id;
}
$countQuery = sqlQueryBuilder()
->select('COUNT(DISTINCT pi.id_preorder_date)')
->from('preorders_items', 'pi')
->andWhere(Operator::equals(['pi.id_user' => $user]));
if ($spec) {
$countQuery->andWhere($spec);
}
return (int) $countQuery->execute()->fetchOne();
}
}