Files
kupshop/bundles/KupShop/OrderDiscountBundle/Util/DiscountManager.php
2025-08-02 16:30:27 +02:00

553 lines
21 KiB
PHP

<?php
namespace KupShop\OrderDiscountBundle\Util;
use KupShop\KupShopBundle\Query\JsonOperator;
use KupShop\OrderDiscountBundle\Actions\Frontend\HandlerInterface;
use KupShop\OrderDiscountBundle\Actions\Frontend\HandlerWithPurchaseStateInterface;
use KupShop\OrderDiscountBundle\Entity\OrderDiscount;
use KupShop\OrderDiscountBundle\OrderDiscountLocator;
use KupShop\OrderDiscountBundle\Translations\OrderDiscountsTranslation;
use KupShop\OrderDiscountBundle\Triggers\GeneratedCouponTrigger;
use KupShop\OrderDiscountBundle\Triggers\UsesCountTrigger;
use KupShop\OrderingBundle\Entity\Purchase\PurchaseState;
use KupShop\OrderingBundle\Exception\CartValidationException;
use KupShop\OrderingBundle\Util\Purchase\PurchaseUtil;
use Query\Operator;
use Query\Translation;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
class DiscountManager
{
/**
* @var OrderDiscountLocator
*/
private $orderDiscountLocator;
/** @var PurchaseUtil */
private $purchaseUtil;
/** @var PurchaseState */
private $purchaseState;
private $actionHandlersData = [];
/** @var HandlerInterface[] */
private $actionHandlers = [];
private array $userMessages = [];
public function __construct(OrderDiscountLocator $orderDiscountLocator, PurchaseUtil $purchaseUtil)
{
$this->orderDiscountLocator = $orderDiscountLocator;
$this->purchaseUtil = $purchaseUtil;
}
public function getUserMessages(): array
{
return $this->userMessages;
}
public function setUserMessages(array $userMessages): void
{
$this->userMessages = $userMessages;
}
public function getOrderDiscountById(int $id): ?OrderDiscount
{
$orderDiscount = sqlQueryBuilder()
->select('*')
->from('order_discounts')
->where(Operator::equals(['id' => $id]))
->execute()->fetchAssociative();
if (!$orderDiscount) {
return null;
}
return $this->loadDiscountsEntities([$id => $orderDiscount])[$id] ?? null;
}
public function getOrderDiscount(array $data): OrderDiscount
{
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers);
return $serializer->denormalize($data, OrderDiscount::class, null);
}
public function getActionHandlers()
{
return $this->actionHandlers;
}
public function getTriggerHandlers()
{
$handlers = [];
$discountsToCheck = $this->getDiscountForCheck();
$discountsEntities = $this->loadDiscountsEntities($discountsToCheck);
foreach ($discountsEntities as $Discount) {
foreach ($Discount->getTriggers() as $trigger) {
if (empty($handlers[$trigger['type']])) {
$triggerService = $this->orderDiscountLocator->getTrigger($trigger['type']);
if ($handler = $triggerService->getFrontendHandler()) {
$handlers[$trigger['type']] = $handler;
}
}
}
}
return $handlers;
}
/**
* Check discounts triggers and apply actions.
*
* @throws \Exception
*/
public function recalculate($discountsToCheck = null): PurchaseState
{
if (is_null($discountsToCheck)) {
$discountsToCheck = $this->getDiscountForCheck();
}
$discountsEntities = $this->loadDiscountsEntities($discountsToCheck);
$userMessages = [];
$delayed_actions = [];
foreach ($discountsEntities as $Discount) {
$applicable = true;
$multiplier = null;
foreach ($Discount->getTriggers() as $trigger) {
$triggerService = $this->orderDiscountLocator->getTrigger($trigger['type']);
$applicable = $triggerService->isApplicable($this->purchaseState,
$Discount,
$trigger['data'],
$triggerService->getHandlerData($this->purchaseState));
if (!$applicable) {
/*if ($error_message = $triggerService->getErrorMessage($this->purchaseState, $trigger['data'])) {
$id = $triggerService->getType().$trigger['id'].'_warning';
$userMessages[$id] = ['message' => $error_message, 'severity' => 'warning'];
}*/
break;
}
if ($mul = $triggerService->getMultiplier($this->purchaseState, $Discount, $trigger['data'])) {
$trigger_type = $trigger['type'];
$multiplier = array_map(function ($v) use ($trigger_type) {
return [$trigger_type => $v];
}, $mul);
}
}
if ($applicable) {
foreach ($Discount->getActions() as $action) {
$actionService = $this->orderDiscountLocator->getAction($action['type']);
if ($actionService->getFrontendHandler()) {
$handler = clone $actionService->getFrontendHandler();
if ($handler instanceof HandlerWithPurchaseStateInterface) {
$handler->setPurchaseState($this->purchaseState);
}
$handler->setActionId($action['id'])
->setOrderDiscount($Discount)
->setActionData($action['data']);
$data = $this->actionHandlersData[$action['id']] ?? [];
// add handled data to action data
$action['data']['handled'] = $handler->handleData($data);
$this->actionHandlers[$action['id']] = $handler;
}
if ($delayed = $actionService->getDelayedExecution()) {
$delayed_actions[$delayed][$action['id']] = [$actionService, $Discount, $action, $multiplier];
continue;
}
if ($multiplier) {
$actionService->applyMultiple($this->purchaseState, $Discount, $action['data'], $multiplier);
} else {
$actionService->applyResult($this->purchaseState, $Discount, $action['data']);
}
$userMessages = array_merge($userMessages, $actionService->getUserMessages($action['id']));
$this->purchaseState->addAction($action['type'], $action);
}
}
}
// po aplikovani vsech slev, jako posledni aplikujeme akce 'Zisk bodů' a potom akce 'Přidat body'
ksort($delayed_actions);
foreach ($delayed_actions as $actions) {
foreach ($actions as $item) {
[$actionService, $Discount, $action, $multiplier] = $item;
if ($multiplier) {
$actionService->applyMultiple($this->purchaseState, $Discount, $action['data'], $multiplier);
} else {
$actionService->applyResult($this->purchaseState, $Discount, $action['data']);
}
$userMessages = array_merge($userMessages, $actionService->getUserMessages($action['id']));
$this->purchaseState->addAction($action['type'], $action);
}
}
$this->setUserMessages($userMessages);
return $this->purchaseUtil->recalculateTotalPrices($this->purchaseState);
}
public function addUserMessages()
{
foreach ($this->userMessages as $key => $message) {
$message['data'] = ['id' => $key];
addUserMessage(...$message);
}
}
/**
* @return OrderDiscount[] $discountsEntities
*
* @throws \Exception
*/
public function loadDiscountsEntities($discountsArray)
{
$triggers = sqlQueryBuilder()->select('*')
->from('order_discounts_triggers')
->where(Operator::inIntArray(array_keys($discountsArray), 'id_order_discount'))
->execute()->fetchAll();
foreach ($triggers as $trigger) {
$trigger['data'] = json_decode($trigger['data'] ?? '', true) ?: [];
$discountsArray[$trigger['id_order_discount']]['triggers'][] = $trigger;
}
$actions = sqlQueryBuilder()->select('*')
->from('order_discounts_actions')
->where(Operator::inIntArray(array_keys($discountsArray), 'id_order_discount'))
->execute()->fetchAll();
foreach ($actions as $action) {
$action['data'] = json_decode($action['data'] ?? '', true) ?: [];
$discountsArray[$action['id_order_discount']]['actions'][] = $action;
}
return array_map(function ($discount) {
return $this->getOrderDiscount($discount);
}, $discountsArray);
}
public function getDiscountForCheck(?array $ignoreTriggers = null): array
{
$discountToCheck = sqlQueryBuilder()->select('od.*')
->from('order_discounts', 'od')
->orderBy('od.position');
$discountToSubstract = sqlQueryBuilder()
->select('odt.id_order_discount')
->from('order_discounts_triggers', 'odt');
foreach ($this->getTriggers() as $trigger) {
if ($ignoreTriggers && in_array($trigger::getType(), $ignoreTriggers)) {
continue;
}
$discountToSubstract->orWhere(
$trigger->getDiscountFilterSpec($this->purchaseState)
);
}
$discountToSubstract->andWhere($this->getActiveSpec());
$discountToCheck
->andWhere("od.id NOT IN ({$discountToSubstract->getSQL()})")
->andWhere($this->getActiveSpec())
->addParameters($discountToSubstract->getParameters(), $discountToSubstract->getParameterTypes())
->andWhere(Translation::coalesceTranslatedFields(OrderDiscountsTranslation::class));
return sqlFetchAll($discountToCheck->execute(), 'id');
}
public function setActionHandlersData(array $actionHandlersData)
{
$this->actionHandlersData = $actionHandlersData;
}
public function setPurchaseState(PurchaseState $purchaseState): void
{
$this->purchaseState = $purchaseState;
}
public function getActiveSpec($field = 'od.active')
{
$active = ['Y'];
if (getAdminUser()) {
$active[] = 'A';
}
return Operator::inStringArray($active, $field);
}
/**
* @param array $order_discounts
*
* @return bool
*
* @throws \Exception
*/
public function couponNumberExists(string $coupon, &$order_discounts = [])
{
// CouponTrigger
$SQL = sqlQueryBuilder()
->select('od.id, od.display_name, od.name')
->from('order_discounts_triggers', 'odt')
->leftJoin('odt', 'order_discounts', 'od', 'odt.id_order_discount = od.id')
->where(Operator::equals(['odt.type' => 'coupon']))
->andWhere($this->getActiveSpec())
->andWhere(JsonOperator::search('odt.data', $coupon))
->orderBy('od.position')
->execute();
if ($SQL->rowCount() > 0) {
$order_discounts = sqlFetchAll($SQL, 'id');
return true;
}
// GeneratedCouponTrigger
$SQL = sqlQueryBuilder()
->select('d.id, dc.id_order_purchased')
->from('discounts_coupons', 'dc')
->join('dc', 'discounts', 'd', 'd.id = dc.id_discount')
->where('dc.date_activated <= NOW() AND (dc.date_from IS NULL OR dc.date_from <= NOW()) AND (dc.date_to IS NULL OR dc.date_to >= NOW())')
->andWhere(Operator::equals(['dc.code' => $coupon, 'dc.used' => 'N']))
->execute();
$row = $SQL->fetch();
if (!$row) {
return false;
}
if ($id_order_purchased = $row['id_order_purchased']) {
$order = new \Order($id_order_purchased);
$order->createFromDB($id_order_purchased);
if (!$this->isOrderFinished($order)) {
return false;
}
}
if ($generate_coupon = $row['id']) {
$SQL = sqlQueryBuilder()
->select('od.id, od.display_name, od.name')
->from('order_discounts_triggers', 'odt')
->leftJoin('odt', 'order_discounts', 'od', 'odt.id_order_discount = od.id')
->where(Operator::equals(['odt.type' => 'generated_coupon']))
->andWhere($this->getActiveSpec())
->andWhere(Operator::equals([JsonOperator::value('odt.data', 'generate_coupon') => $generate_coupon]))
->orderBy('od.position')
->execute();
if ($SQL->rowCount() > 0) {
$order_discounts = sqlFetchAll($SQL, 'id');
return true;
}
}
return false;
}
/**
* @return bool|null
*
* @throws CartValidationException
*/
public function isCouponValid(string $coupon, ?array $order_discounts = null)
{
if (is_null($order_discounts)) {
$this->couponNumberExists($coupon, $order_discounts);
}
if (!$order_discounts) {
return false;
}
$this->purchaseState->addCoupon($coupon);
$discountsToCheck = $this->getDiscountForCheck();
if ($intersect = array_intersect_key($discountsToCheck, $order_discounts)) {
$order_discounts = $intersect;
} else {
$discountsToCheck = $order_discounts;
}
$triggers_types = sqlQueryBuilder()->select('DISTINCT odt.type')
->from('order_discounts_triggers', 'odt')
->where(Operator::inIntArray(array_keys($discountsToCheck), 'odt.id_order_discount'))
->andWhere(Operator::not(Operator::equals(['odt.type' => UsesCountTrigger::getType()])))
->execute()->fetchAll();
$triggers_types = array_column($triggers_types, 'type');
$triggers_specs = [];
foreach ($this->orderDiscountLocator->getTriggers() as $trigger) {
if (in_array($trigger->getType(), $triggers_types)) {
$triggers_specs[] = $trigger->getDiscountFilterSpec($this->purchaseState);
}
}
$triggers_specs = array_filter($triggers_specs);
$false_triggers = [];
if (!empty($triggers_specs)) {
$triggers_specs = Operator::orX($triggers_specs);
$false_triggers = sqlFetchAll(
sqlQueryBuilder()->select('odt.id')
->from('order_discounts_triggers', 'odt')
->where(Operator::inIntArray(array_keys($discountsToCheck), 'odt.id_order_discount'))
->andWhere(Operator::not(Operator::equals(['odt.type' => UsesCountTrigger::getType()])))
->andWhere($triggers_specs)
->execute(),
'id');
}
$discountsEntities = $this->loadDiscountsEntities($discountsToCheck);
$valid = false;
$error_message = '';
foreach ($discountsEntities as $Discount) {
$applicable = true;
foreach ($Discount->getTriggers() as $trigger) {
$triggerService = $this->orderDiscountLocator->getTrigger($trigger['type']);
if (array_key_exists($trigger['id'], $false_triggers)) {
$applicable = false;
if (!$intersect || array_key_exists($Discount->getId(), $order_discounts)) {
$error_message = $triggerService->getErrorMessage($this->purchaseState, $trigger['data']);
if (empty($error_message)) {
$error_message = translate_shop('error', 'order')['coupon_not_valid'];
}
}
break;
}
$applicable = $triggerService->isApplicable($this->purchaseState, $Discount, $trigger['data']);
if (!$applicable) {
if (!$intersect || array_key_exists($Discount->getId(), $order_discounts)) {
$error_message = $triggerService->getErrorMessage($this->purchaseState, $trigger['data']);
if (empty($error_message)) {
$error_message = translate_shop('error', 'order')['coupon_not_valid'];
}
}
if (!$intersect) {
throw new CartValidationException($error_message);
}
break;
}
}
if ($applicable && $intersect) {
$this->purchaseState->addUsedDiscount($Discount->getId());
if (array_key_exists($Discount->getId(), $order_discounts)) {
$valid = true;
// proverit jestli je zakazano uplatnit více kódů na jednu objednávku
foreach ($Discount->getTriggers() as $trigger) {
if ($trigger['type'] == 'coupon') {
$multiple = $trigger['data']['multiple'] ?? 'N';
if ($multiple == 'N') {
$codes = array_map('strtolower', $trigger['data']['codes']);
$activeCoupons = array_map('strtolower', $this->purchaseState->getActiveCoupons());
$codes = array_intersect($activeCoupons, $codes);
$valid = (count($codes) == 1);
break;
}
}
if ($trigger['type'] == 'generated_coupon') {
$multiple = $trigger['data']['multiple'] ?? 'N';
if ($multiple == 'N') {
/** @var GeneratedCouponTrigger $triggerService */
$triggerService = $this->orderDiscountLocator->getTrigger($trigger['type']);
$valid = count($triggerService->getActiveCoupons($this->purchaseState, $trigger['data'])) < 2;
break;
}
}
}
break;
}
}
}
if (!$valid && !empty($error_message)) {
throw new CartValidationException($error_message);
}
return $valid;
}
protected function isOrderFinished(\Order $order)
{
if (!$order->isActive() || !$order->isPaid()) {
return false;
}
return true;
}
public function getTriggers(): array
{
if (!$this->purchaseState) {
return [];
}
$triggers = [];
foreach ($this->orderDiscountLocator->getTriggers() as $trigger) {
if ($handler = $trigger->getFrontendHandler()) {
$handler->setPersistentData($this->purchaseState);
$persistentData = $handler->handleData($this->actionHandlersData);
$this->purchaseState->setCustomData([$trigger::getType() => $persistentData]);
}
$triggers[] = $trigger;
}
return $triggers;
}
public function setActionsFromUsedDiscounts(PurchaseState $purchaseState): void
{
$purchaseState->setActions([]);
foreach ($purchaseState->getUsedDiscounts() as $id_discount) {
if ($discount = $this->getOrderDiscountById($id_discount)) {
foreach ($discount->actions as $action) {
$purchaseState->addAction($action['type'], $action);
}
}
}
}
public function applyActions(PurchaseState $purchaseState, array $actions): PurchaseState
{
foreach ($actions as $action) {
if ($discount = $this->getOrderDiscountById($action['id_order_discount'])) {
$actionService = $this->orderDiscountLocator->getAction($action['type']);
$actionService->applyResult($purchaseState, $discount, $action['data'] ?? []);
}
}
return $purchaseState;
}
public function getCurrentActions($types = []): array
{
$actions = [];
$discountsToCheck = $this->getDiscountForCheck();
$discountsEntities = $this->loadDiscountsEntities($discountsToCheck);
foreach ($discountsEntities as $Discount) {
foreach ($Discount->getActions() as $action) {
if (empty($types) || in_array($action['type'], $types)) {
$actions[$action['type']][] = $action;
}
}
}
return $actions;
}
}