<?php

namespace Ekyna\Component\Commerce\Shipment\EventListener;

use Ekyna\Component\Commerce\Exception;
use Ekyna\Component\Commerce\Shipment\Model;
use Ekyna\Component\Commerce\Stock\Assigner\StockUnitAssignerInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class AbstractShipmentItemListener
 * @package Ekyna\Component\Commerce\Shipment\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractShipmentItemListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var StockUnitAssignerInterface
     */
    protected $stockUnitAssigner;

    /**
     * Sets the persistence helper.
     *
     * @param PersistenceHelperInterface $helper
     */
    public function setPersistenceHelper(PersistenceHelperInterface $helper)
    {
        $this->persistenceHelper = $helper;
    }

    /**
     * Sets the stock assigner.
     *
     * @param StockUnitAssignerInterface $assigner
     */
    public function setStockUnitAssigner(StockUnitAssignerInterface $assigner)
    {
        $this->stockUnitAssigner = $assigner;
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $item = $this->getShipmentItemFromEvent($event);
        $shipment = $item->getShipment();

        $this->scheduleShipmentContentChangeEvent($shipment);

        // If shipment state is stockable
        if (Model\ShipmentStates::isStockableState($shipment->getState())) {
            // Assign shipment item to stock units
            $this->stockUnitAssigner->assignShipmentItem($item);
        }
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $item = $this->getShipmentItemFromEvent($event);
        $shipment = $item->getShipment();

        $this->preventSaleItemChange($item);

        // Check whether or not the stock impact has been made by the shipment listener
        if (!empty($stateCs = $this->persistenceHelper->getChangeSet($shipment, 'state'))) {
            // If shipment just did a stockable state transition
            if (
                Model\ShipmentStates::hasChangedFromStockable($stateCs) ||
                Model\ShipmentStates::hasChangedToStockable($stateCs)
            ) {
                // Abort (done by the shipment listener)
                return;
            }
        }

        // If shipment is in a stockable state and quantity has changed
        if (
            Model\ShipmentStates::isStockableState($shipment->getState()) &&
            $this->persistenceHelper->isChanged($item, 'quantity')
        ) {
            // Apply shipment item to stock units
            $this->stockUnitAssigner->applyShipmentItem($item);

            $this->scheduleShipmentContentChangeEvent($shipment);
        }
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $item = $this->getShipmentItemFromEvent($event);

        // Get shipment from change set if null
        if (null === $shipment = $item->getShipment()) {
            $shipment = $this->persistenceHelper->getChangeSet($item, 'shipment')[0];
        }

        // If shipment state has changed to stockable
        $stateCs = $this->persistenceHelper->getChangeSet($shipment, 'state');
        if (!empty($stateCs) && Model\ShipmentStates::hasChangedToStockable($stateCs)) {
            // Abort (item was not assigned)
            return;
        }

        // If shipment is (or was) in a stockable state
        if (
            Model\ShipmentStates::isStockableState($shipment->getState()) ||
            (!empty($stateCs) && Model\ShipmentStates::hasChangedFromStockable($stateCs))
        ) {
            // Detach shipment item to stock units
            $this->stockUnitAssigner->detachShipmentItem($item);
        }

        $this->scheduleShipmentContentChangeEvent($shipment);
    }

    /**
     * Prevents the sale item from changing.
     *
     * @param Model\ShipmentItemInterface $item
     */
    abstract protected function preventSaleItemChange(Model\ShipmentItemInterface $item);

    /**
     * Schedules the shipment content change event.
     *
     * @param Model\ShipmentInterface $shipment
     */
    abstract protected function scheduleShipmentContentChangeEvent(Model\ShipmentInterface $shipment);

    /**
     * Returns the shipment item from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return Model\ShipmentItemInterface
     * @throws Exception\InvalidArgumentException
     */
    abstract protected function getShipmentItemFromEvent(ResourceEventInterface $event);
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints;

use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as BaseConstraint;

/**
 * Phone number constraint.
 *
 * @Annotation
 */
class PhoneNumber extends BaseConstraint
{
    /**
     * Returns whether the given type is valid.
     *
     * @param string $type
     *
     * @return bool
     */
    public function isValidType($type)
    {
        return in_array($type, array(
            self::ANY,
            self::FIXED_LINE,
            self::MOBILE,
            self::PAGER,
            self::PERSONAL_NUMBER,
            self::PREMIUM_RATE,
            self::SHARED_COST,
            self::TOLL_FREE,
            self::UAN,
            self::VOIP,
            self::VOICEMAIL,
        ), true);
    }

    /**
     * Returns the first configured type.
     *
     * @return string
     */
    public function getType()
    {
        if (is_string($this->type)) {
            $type = $this->type;
        } elseif (is_array($this->type)) {
            $type = reset($this->type);
        } else {
            $type = null;
        }

        return $this->isValidType($type) ? $type : self::ANY;
    }

    /**
     * Returns the configured types.
     *
     * @return array
     */
    public function getTypes()
    {
        if (is_string($this->type)) {
            $types = array($this->type);
        } elseif (is_array($this->type)) {
            $types = $this->type;
        } else {
            $types =  array();
        }

        $types = array_filter($types, array($this, 'isValidType'));

        return empty($types) ? array(self::ANY) : $types;
    }

    /**
     * Returns the violation message for the first configured type.
     *
     * @return null|string
     */
    public function getMessage()
    {
        // TODO Deal with multiple types

        if (null !== $this->message) {
            return $this->message;
        }

        switch ($this->getType()) {
            case self::FIXED_LINE:
                return 'This value is not a valid fixed-line number.';
            case self::MOBILE:
                return 'This value is not a valid mobile number.';
            case self::PAGER:
                return 'This value is not a valid pager number.';
            case self::PERSONAL_NUMBER:
                return 'This value is not a valid personal number.';
            case self::PREMIUM_RATE:
                return 'This value is not a valid premium-rate number.';
            case self::SHARED_COST:
                return 'This value is not a valid shared-cost number.';
            case self::TOLL_FREE:
                return 'This value is not a valid toll-free number.';
            case self::UAN:
                return 'This value is not a valid UAN.';
            case self::VOIP:
                return 'This value is not a valid VoIP number.';
            case self::VOICEMAIL:
                return 'This value is not a valid voicemail access number.';
        }

        return 'This value is not a valid phone number.';
    }

    /**
     * @inheritDoc
     */
    public function validatedBy()
    {
        return PhoneNumberValidator::class;
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer;

use Ekyna\Component\Commerce\Support\Model\TicketAttachmentInterface;

/**
 * Class TicketAttachmentNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class TicketAttachmentNormalizer extends AbstractAttachmentNormalizer
{
    /**
     * @inheritDoc
     *
     * @param TicketAttachmentInterface $attachment
     */
    public function normalize($attachment, $format = null, array $context = [])
    {
        if ($this->contextHasGroup(['Default', 'Ticket', 'TicketMessage', 'TicketAttachment'], $context)) {
            $data = $this->normalizeAttachment($attachment);

            $data['message'] = $attachment->getMessage()->getId();

            return $data;
        }

        return parent::normalize($attachment, $format, $context);
    }

    /**
     * @inheritDoc
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        //$object = parent::denormalize($data, $class, $format, $context);

        throw new \Exception('Not yet implemented');
    }

    /**
     * @inheritDoc
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof TicketAttachmentInterface;
    }

    /**
     * @inheritDoc
     */
    public function supportsDenormalization($data, $type, $format = null)
    {
        return class_exists($type) && is_subclass_of($type, TicketAttachmentInterface::class);
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Updater;

use Ekyna\Component\Commerce\Exception\StockLogicException;
use Ekyna\Component\Commerce\Stock\Dispatcher\StockAssignmentDispatcherInterface;
use Ekyna\Component\Commerce\Stock\Manager\StockUnitManagerInterface;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;
use Ekyna\Component\Commerce\Stock\Resolver\StockUnitResolverInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class StockUnitUpdater
 * @package Ekyna\Component\Commerce\Stock\Updater
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class StockUnitUpdater implements StockUnitUpdaterInterface
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var StockUnitResolverInterface
     */
    protected $unitResolver;

    /**
     * @var StockUnitManagerInterface
     */
    protected $unitManager;

    /**
     * @var StockAssignmentDispatcherInterface
     */
    protected $assignmentDispatcher;

    /**
     * Constructor.
     *
     * @param PersistenceHelperInterface         $persistenceHelper
     * @param StockUnitResolverInterface         $unitResolver
     * @param StockUnitManagerInterface          $unitManager
     * @param StockAssignmentDispatcherInterface $assignmentDispatcher
     */
    public function __construct(
        PersistenceHelperInterface $persistenceHelper,
        StockUnitResolverInterface $unitResolver,
        StockUnitManagerInterface $unitManager,
        StockAssignmentDispatcherInterface $assignmentDispatcher
    ) {
        $this->persistenceHelper = $persistenceHelper;
        $this->unitResolver = $unitResolver;
        $this->unitManager = $unitManager;
        $this->assignmentDispatcher = $assignmentDispatcher;
    }

    /**
     * @inheritdoc
     */
    public function updateOrdered(StockUnitInterface $stockUnit, $quantity, $relative = true)
    {
        if ($relative) {
            $quantity = $stockUnit->getOrderedQuantity() + $quantity;
        }
        if (0 > $quantity) {
            throw new StockLogicException("Unexpected ordered quantity.");
        }

        // Prevent ordered quantity to be set as lower than the received quantity
        if ($quantity < $stockUnit->getReceivedQuantity()) {
            throw new StockLogicException("The ordered quantity can't be lower than the received quantity.");
        }

        $stockUnit->setOrderedQuantity($quantity);

        if ($this->handleOverflow($stockUnit)) {
            // Stock unit persistence has been made by assignment dispatcher.
            return;
        }

        $this->unitManager->persistOrRemove($stockUnit);
    }

    /**
     * @inheritdoc
     */
    public function updateReceived(StockUnitInterface $stockUnit, $quantity, $relative = true)
    {
        if ($relative) {
            $quantity = $stockUnit->getReceivedQuantity() + $quantity;
        }
        if (0 > $quantity) {
            throw new StockLogicException("Unexpected received quantity.");
        }

        // Prevent received quantity to be set as greater than the ordered quantity
        if ($quantity > $stockUnit->getOrderedQuantity()) {
            throw new StockLogicException("The received quantity can't be greater than the ordered quantity.");
        }

        $stockUnit->setReceivedQuantity($quantity);

        $this->unitManager->persistOrRemove($stockUnit);
    }

    /**
     * @inheritdoc
     */
    public function updateAdjusted(StockUnitInterface $stockUnit, $quantity, $relative = true)
    {
        if ($relative) {
            $quantity = $stockUnit->getAdjustedQuantity() + $quantity;
        }

        if ($quantity + $stockUnit->getReceivedQuantity() < $stockUnit->getShippedQuantity()) {
            throw new StockLogicException("Unexpected adjusted quantity.");
        }

        $stockUnit->setAdjustedQuantity($quantity);

        if ($this->handleOverflow($stockUnit)) {
            // Stock unit persistence has been made by assignment dispatcher.
            return;
        }

        $this->unitManager->persistOrRemove($stockUnit);
    }

    /**
     * @inheritdoc
     */
    public function updateSold(StockUnitInterface $stockUnit, $quantity, $relative = true)
    {
        if ($relative) {
            $quantity = $stockUnit->getSoldQuantity() + $quantity;
        }
        if (0 > $quantity) {
            throw new StockLogicException("Unexpected sold quantity.");
        }

        // Prevent sold quantity to be set as lower than the shipped quantity
        if ($quantity < $stockUnit->getShippedQuantity()) {
            throw new StockLogicException("The sold quantity can't be lower than the shipped quantity.");
        }

        $stockUnit->setSoldQuantity($quantity);

        $this->unitManager->persistOrRemove($stockUnit);
    }

    /**
     * @inheritdoc
     */
    public function updateShipped(StockUnitInterface $stockUnit, $quantity, $relative = true)
    {
        if ($relative) {
            $quantity = $stockUnit->getShippedQuantity() + $quantity;
        }
        if (0 > $quantity) {
            throw new StockLogicException("Unexpected shipped quantity.");
        }

        // Prevent shipped quantity to be set as greater than the sold or received quantity
        if ($quantity > $stockUnit->getSoldQuantity()) {
            throw new StockLogicException("The shipped quantity can't be greater than the sold quantity.");
        }
        if ($quantity > $stockUnit->getReceivedQuantity() + $stockUnit->getAdjustedQuantity()) {
            throw new StockLogicException("The shipped quantity can't be greater than the sum (received + adjusted) quantity.");
        }

        $stockUnit->setShippedQuantity($quantity);

        $this->unitManager->persistOrRemove($stockUnit);
    }

    /**
     * @inheritdoc
     */
    public function updateEstimatedDateOfArrival(StockUnitInterface $stockUnit, \DateTime $date = null)
    {
        if ($date != $stockUnit->getEstimatedDateOfArrival()) {
            $stockUnit->setEstimatedDateOfArrival($date);

            $this->persistenceHelper->persistAndRecompute($stockUnit, true);
        }
    }

    /**
     * Checks stock unit overflow (sold > ordered + adjusted) and fixes assignments if needed.
     *
     * @param StockUnitInterface $stockUnit
     *
     * @return bool Whether assignment(s) has been moved.
     *
     * @throws StockLogicException
     */
    private function handleOverflow(StockUnitInterface $stockUnit)
    {
        // TODO Abort if stock unit is new ...

        // We don't care about shipped quantities because of the 'ordered > received > shipped' rule.
        $overflow = $stockUnit->getSoldQuantity()
            - $stockUnit->getOrderedQuantity()
            - $stockUnit->getAdjustedQuantity();

        // Abort if no overflow
        if (0 == $overflow) {
            return false;
        }

        $subject = $stockUnit->getSubject();

        // Positive case : too much sold quantity
        if (0 < $overflow) {
            // Try to move sold overflow to other pending/ready stock units
            // TODO prefer ready units with enough quantity
            $targetStockUnits = $this->unitResolver->findPendingOrReady($subject);
            foreach ($targetStockUnits as $targetStockUnit) {
                // Skip the stock unit we're applying
                if ($targetStockUnit === $stockUnit) {
                    continue;
                }

                $overflow -= $this->assignmentDispatcher->moveAssignments($stockUnit, $targetStockUnit, $overflow);

                if (0 == $overflow) {
                    return true; // We're done dispatching sold quantity
                }
            }

            // Try to move sold overflow to a linkable stock unit
            if (null !== $targetStockUnit = $this->unitResolver->findLinkable($subject)) {
                if ($targetStockUnit !== $stockUnit) {
                    $overflow -= $this->assignmentDispatcher->moveAssignments($stockUnit, $targetStockUnit, $overflow);
                }
            }

            // Move sold overflow to a new stock unit
            if (0 < $overflow) {
                $newStockUnit = $this->unitResolver->createBySubject($subject, $stockUnit);

                // Pre persist stock unit
                $this->persistenceHelper->persistAndRecompute($newStockUnit, false);

                $overflow -= $this->assignmentDispatcher->moveAssignments($stockUnit, $newStockUnit, $overflow);
            }

            if (0 != $overflow) {
                throw new StockLogicException("Failed to fix stock unit sold quantity overflow.");
            }

            return true;
        }

        // Negative case : not enough sold quantity
        if (null !== $sourceUnit = $this->unitResolver->findLinkable($subject)) {
            if ($sourceUnit === $stockUnit) {
                return false;
            }
            if (0 != $this->assignmentDispatcher->moveAssignments($sourceUnit, $stockUnit, -$overflow, SORT_ASC)) {
                return true;
            }
        }

        return false;
    }
}


<?php

namespace Ekyna\Component\Commerce\Payment\EventListener;

use Ekyna\Component\Commerce\Common\Currency\CurrencyConverterInterface;
use Ekyna\Component\Commerce\Common\Generator\KeyGeneratorInterface;
use Ekyna\Component\Commerce\Common\Generator\NumberGeneratorInterface;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Customer\Updater\CustomerUpdaterInterface;
use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Payment\Model\PaymentInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentStates;
use Ekyna\Component\Commerce\Payment\Model\PaymentSubjectInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class AbstractPaymentListener
 * @package Ekyna\Component\Commerce\Payment\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractPaymentListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var NumberGeneratorInterface
     */
    protected $numberGenerator;

    /**
     * @var KeyGeneratorInterface
     */
    protected $keyGenerator;

    /**
     * @var CustomerUpdaterInterface
     */
    protected $customerUpdater;

    /**
     * @var CurrencyConverterInterface
     */
    protected $currencyConverter;

    /**
     * Sets the persistence helper.
     *
     * @param PersistenceHelperInterface $helper
     */
    public function setPersistenceHelper(PersistenceHelperInterface $helper)
    {
        $this->persistenceHelper = $helper;
    }

    /**
     * Sets the number generator.
     *
     * @param NumberGeneratorInterface $numberGenerator
     */
    public function setNumberGenerator(NumberGeneratorInterface $numberGenerator)
    {
        $this->numberGenerator = $numberGenerator;
    }

    /**
     * Sets the key generator.
     *
     * @param KeyGeneratorInterface $keyGenerator
     */
    public function setKeyGenerator(KeyGeneratorInterface $keyGenerator)
    {
        $this->keyGenerator = $keyGenerator;
    }

    /**
     * Sets the customer updater.
     *
     * @param CustomerUpdaterInterface $customerUpdater
     */
    public function setCustomerUpdater(CustomerUpdaterInterface $customerUpdater)
    {
        $this->customerUpdater = $customerUpdater;
    }

    /**
     * Sets the currency converter.
     *
     * @param CurrencyConverterInterface $converter
     */
    public function setCurrencyConverter(CurrencyConverterInterface $converter)
    {
        $this->currencyConverter = $converter;
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $payment = $this->getPaymentFromEvent($event);

        // Generate number and key
        $changed = $this->generateNumber($payment);
        $changed |= $this->generateKey($payment);

        // Exchange rate
        $changed |= $this->updateExchangeRate($payment);

        // Completed state
        $changed |= $this->handleCompletedState($payment);

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($payment);
        }

        $sale = $this->getSaleFromPayment($payment);

        $this->customerUpdater->handlePaymentInsert($payment);

        $this->scheduleSaleContentChangeEvent($sale);
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws LogicException
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $payment = $this->getPaymentFromEvent($event);

        // Generate number and key
        $changed = $this->generateNumber($payment);
        $changed |= $this->generateKey($payment);

        if ($this->persistenceHelper->isChanged($payment, 'state')) {
            $changed |= $this->handleCompletedState($payment);
        }

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($payment);
        }

        if ($this->persistenceHelper->isChanged($payment, 'method')) {
            $methodCs = $this->persistenceHelper->getChangeSet($payment, 'method');
            /** @var \Ekyna\Component\Commerce\Payment\Model\PaymentMethodInterface $fromMethod */
            $fromMethod = $methodCs[0];
            /** @var \Ekyna\Component\Commerce\Payment\Model\PaymentMethodInterface $toMethod */
            $toMethod = $methodCs[1];

            if ($fromMethod->isManual() && !$toMethod->isManual()) {
                throw new LogicException("Payment method can't be changed from manual to non manual method.");
            } elseif (!$fromMethod->isManual()) {
                throw new LogicException("Payment method can't be changed.");
            }
        }

        if ($this->persistenceHelper->isChanged($payment, ['amount', 'state'])) {
            $this->scheduleSaleContentChangeEvent($this->getSaleFromPayment($payment));

            $this->customerUpdater->handlePaymentUpdate($payment);
        }
    }

    /**
     * Handle the 'completed' state.
     *
     * @param PaymentInterface $payment
     *
     * @return bool Whether or not the shipment has been changed.
     */
    protected function handleCompletedState(PaymentInterface $payment)
    {
        $changed = false;

        $state = $payment->getState();
        $completedAt = $payment->getCompletedAt();

        if (PaymentStates::isPaidState($state) && null === $completedAt) {
            $payment->setCompletedAt(new \DateTime());
            $changed = true;
        } elseif (!PaymentStates::isPaidState($state) && null !== $completedAt) {
            $payment->setCompletedAt(null);
            $changed = true;
        }

        return $changed;
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $payment = $this->getPaymentFromEvent($event);

        $this->customerUpdater->handlePaymentDelete($payment);

        $sale = $this->getSaleFromPayment($payment);

        $sale->removePayment($payment);

        $this->scheduleSaleContentChangeEvent($sale);
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $payment = $this->getPaymentFromEvent($event);

        // Pre load sale's payments
        $payment->getSale()->getPayments()->toArray();

        if (!in_array($payment->getState(), PaymentStates::getDeletableStates())) {
            throw new IllegalOperationException(); // TODO reason message
        }
    }

    /**
     * Generates the payment number.
     *
     * @param PaymentInterface $payment
     *
     * @return bool Whether the payment has been changed or not.
     */
    protected function generateNumber(PaymentInterface $payment)
    {
        if (0 == strlen($payment->getNumber())) {
            $this->numberGenerator->generate($payment);

            return true;
        }

        return false;
    }

    /**
     * Generates the payment key.
     *
     * @param PaymentInterface $payment
     *
     * @return bool Whether the payment has been changed or not.
     */
    protected function generateKey(PaymentInterface $payment)
    {
        if (0 == strlen($payment->getKey())) {
            $this->keyGenerator->generate($payment);

            return true;
        }

        return false;
    }

    /**
     * Updates the payment exchange rate.
     *
     * @param PaymentInterface $payment
     *
     * @return bool Whether the payment has been changed or not.
     */
    protected function updateExchangeRate(PaymentInterface $payment)
    {
        if (null !== $payment->getExchangeRate()) {
            return false;
        }

        $date = new \DateTime();

        $rate = $this->currencyConverter->getRate(
            $this->currencyConverter->getDefaultCurrency(),
            $payment->getCurrency()->getCode(),
            $date
        );

        $payment
            ->setExchangeRate($rate)
            ->setExchangeDate($date);

        return true;
    }

    /**
     * Returns the shipment's sale.
     *
     * @param PaymentInterface $payment
     *
     * @return SaleInterface|PaymentSubjectInterface
     */
    protected function getSaleFromPayment(PaymentInterface $payment)
    {
        if (null === $sale = $payment->getSale()) {
            $cs = $this->persistenceHelper->getChangeSet($payment, $this->getSalePropertyPath());
            if (!empty($cs)) {
                $sale = $cs[0];
            }
        }

        if (!$sale instanceof SaleInterface) {
            throw new RuntimeException("Failed to retrieve shipment's sale.");
        }

        // Pre-load / initialize the sale
        $sale->getState();

        return $sale;
    }

    /**
     * Schedules the sale content change event.
     *
     * @param SaleInterface $sale
     */
    abstract protected function scheduleSaleContentChangeEvent(SaleInterface $sale);

    /**
     * Returns the payment from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return PaymentInterface
     * @throws InvalidArgumentException
     */
    abstract protected function getPaymentFromEvent(ResourceEventInterface $event);

    /**
     * Returns the invoice's sale property path.
     *
     * @return string
     */
    abstract protected function getSalePropertyPath();
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository;

use Ekyna\Component\Commerce\Common\Model\CountryInterface;
use Ekyna\Component\Commerce\Shipment\Entity\ShipmentMessage;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentStates;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentZoneInterface;
use Ekyna\Component\Commerce\Shipment\Repository\ShipmentMethodRepositoryInterface;
use Ekyna\Component\Resource\Doctrine\ORM\TranslatableResourceRepository;

/**
 * Class ShipmentMethodRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class ShipmentMethodRepository extends TranslatableResourceRepository implements ShipmentMethodRepositoryInterface
{
    /**
     * @var \Doctrine\ORM\Query
     */
    private $findAvailableByCountryAndWeightQuery;

    /**
     * {@inheritdoc}
     */
    public function createNew()
    {
        /** @var \Ekyna\Component\Commerce\Shipment\Model\ShipmentMethodInterface $method */
        $method = parent::createNew();

        foreach (ShipmentStates::getNotifiableStates() as $state) {
            $message = new ShipmentMessage();
            $method->addMessage($message->setState($state));
        }

        return $method;
    }

    /**
     * @inheritdoc
     */
    public function findHavingPrices(ShipmentZoneInterface $zone = null)
    {
        $qb = $this->getCollectionQueryBuilder();
        $qb
            ->join('o.prices', 'p')
            ->addGroupBy('o.id')
            ->addOrderBy('o.position', 'ASC');

        $parameters = [];

        if (null !== $zone) {
            $qb->andWhere($qb->expr()->eq('p.zone', ':zone'));
            $parameters['zone'] = $zone;
        }

        return $qb
            ->getQuery()
            ->setParameters($parameters)
            ->getResult();
    }

    /**
     * @inheritdoc
     */
    public function findAvailableByCountryAndWeight(CountryInterface $country, $weight)
    {
        return $this
            ->getFindAvailableByCountryAndWeightQuery()
            ->setParameters([
                'country'   => $country,
                'weight'    => $weight,
                'enabled'   => true,
                'available' => true,
            ])
            ->getResult();
    }

    /**
     * Returns the "find available by country and weight" query.
     *
     * @return \Doctrine\ORM\Query
     */
    private function getFindAvailableByCountryAndWeightQuery()
    {
        if (null === $this->findAvailableByCountryAndWeightQuery) {
            $qb = $this->getCollectionQueryBuilder();

            $this->findAvailableByCountryAndWeightQuery = $qb
                ->join('o.prices', 'p')
                ->join('p.zone', 'z')
                ->andWhere($qb->expr()->isMemberOf(':country', 'z.countries'))
                ->andWhere($qb->expr()->gte('p.weight', ':weight'))
                ->andWhere($qb->expr()->eq('o.enabled', ':enabled'))
                ->andWhere($qb->expr()->eq('o.available', ':available'))
                ->addOrderBy('o.position', 'ASC')
                ->getQuery();
        }

        return $this->findAvailableByCountryAndWeightQuery;
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer;

use Ekyna\Component\Commerce\Customer\Model\CustomerInterface;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

/**
 * Class CustomerNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CustomerNormalizer extends AbstractResourceNormalizer
{
    /**
     * @inheritdoc
     *
     * @param CustomerInterface $customer
     */
    public function normalize($customer, $format = null, array $context = [])
    {
        if ($format === 'csv' && $this->contextHasGroup('TableExport', $context)) {
            return (string)$customer;
        }

        $data = parent::normalize($customer, $format, $context);

        $parent = $customer->getParent();

        if ($this->contextHasGroup(['Default', 'Customer', 'Search', 'Summary'], $context)) {
            $data = array_replace($data, [
                'number'     => $customer->getNumber(),
                'company'    => $customer->getCompany(),
                'email'      => $customer->getEmail(),
                'first_name' => $customer->getFirstName(),
                'last_name'  => $customer->getLastName(),
                'phone'      => $this->normalizeObject($customer->getPhone(), $format, $context),
                'mobile'     => $this->normalizeObject($customer->getMobile(), $format, $context),
                'parent'     => $parent ? $parent->getId() : null,
                'currency'   => $customer->getCurrency()->getCode(),
                'locale'     => $customer->getLocale(),
            ]);
        }

        if ($this->contextHasGroup('Summary', $context)) {
            $payment = $parent ? $parent : $customer;

            $data = array_replace($data, [
                'group'               => (string)$customer->getCustomerGroup(),
                'parent'              => (string)$parent,
                'vat_number'          => $payment->getVatNumber(),
                'vat_valid'           => $payment->isVatValid(),
                'payment_term'        => (string)$payment->getPaymentTerm(),
                'outstanding_limit'   => $payment->getOutstandingLimit(),
                'outstanding_balance' => $payment->getOutstandingBalance(),
                'credit_balance'      => $payment->getCreditBalance(),
                'description'         => $payment->getDescription(),
            ]);
        }

        return $data;
    }

    /**
     * @inheritdoc
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        //$object = parent::denormalize($data, $class, $format, $context);

        throw new \Exception('Not yet implemented');
    }

    /**
     * @inheritdoc
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof CustomerInterface;
    }

    /**
     * @inheritdoc
     */
    public function supportsDenormalization($data, $type, $format = null)
    {
        return class_exists($type) && is_subclass_of($type, CustomerInterface::class);
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Prioritizer;

use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentStates;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;

/**
 * Class UnitCandidate
 * @package Ekyna\Component\Commerce\Stock\Prioritizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class UnitCandidate
{
    /**
     * Builds a new unit candidate.
     *
     * @param StockUnitInterface $unit
     * @param SaleInterface      $sale
     *
     * @return UnitCandidate
     */
    public static function build(StockUnitInterface $unit, SaleInterface $sale)
    {
        $releasable = 0;
        $map = [];
        foreach ($unit->getStockAssignments() as $a) {
            // Ignore assignments from the same sale (Should be impossible)
            /** @var \Ekyna\Component\Commerce\Shipment\Model\ShipmentSubjectInterface $s */
            if ($sale === $s = $a->getSaleItem()->getSale()) {
                continue;
            }

            // Ignore assignments from preparation sales
            if ($s->getShipmentState() === ShipmentStates::STATE_PREPARATION) {
                continue;
            }

            if (0 < $d = $a->getSoldQuantity() - $a->getShippedQuantity()) {
                $releasable += $d;
                $map[$a->getId()] = $d;
            }
        }

        arsort($map, \SORT_NUMERIC);

        $candidate = new static;

        $candidate->unit = $unit;
        $candidate->shippable = $unit->getShippableQuantity();
        $candidate->reservable = $unit->getReservableQuantity();
        $candidate->releasable = $releasable;
        $candidate->map = $map;

        return $candidate;
    }

    /**
     * @var StockUnitInterface
     */
    public $unit;

    /**
     * @var float
     */
    public $shippable;

    /**
     * @var float
     */
    public $reservable;

    /**
     * @var float
     */
    public $releasable;

    /**
     * @var array
     */
    public $map;

    /**
     * @var AssignmentCombination
     */
    public $combination;

    /**
     * Returns the best assignments combination for the given quantity.
     *
     * @param float $quantity
     * @param bool  $reset
     *
     * @return AssignmentCombination
     */
    public function getCombination($quantity, $reset = false)
    {
        if (null !== $this->combination && !$reset) {
            return $this->combination;
        }

        $this->combination = null;

        if (!empty($combinations = $this->buildCombinations($quantity))) {
            // Sort combinations: prefer closest, then greater, then lower, finally smaller
            usort($combinations, function (AssignmentCombination $a, AssignmentCombination $b) use ($quantity) {
                if ($a->diff == $b->diff) {
                    if ($a->size == $b->size) {
                        return 0;
                    }

                    return $a->size < $b->size ? -1 : 1;
                }

                if (0 <= $a->diff) {
                    return intval(0 > $b->diff ? -1 : $a->diff - $b->diff);
                }

                return intval(0 < $b->diff ? 1 : $b->diff - $a->diff);
            });

            $this->combination = reset($combinations);
        }

        return $this->combination;
    }

    /**
     * Returns the stock assignment by its ID.
     *
     * @param int $id
     *
     * @return \Ekyna\Component\Commerce\Stock\Model\StockAssignmentInterface|null
     */
    public function getAssignmentById($id)
    {
        foreach ($this->unit->getStockAssignments() as &$assignment) {
            if ($assignment->getId() === $id) {
                return $assignment;
            }
        }

        return null;
    }

    /**
     * Builds the assignments releasable quantity combination list.
     *
     * @param float $quantity
     *
     * @return AssignmentCombination[]
     */
    private function buildCombinations($quantity)
    {
        if (empty($this->map)) {
            return [];
        }

        $combinations = [];

        // Size 1
        foreach ($this->map as $id => $qty) {
            $combinations[] = new AssignmentCombination([$id => $qty], $diff = $qty - $quantity);

            if ($diff == 0) {
                return $combinations;
            }
        }

        // Size 1 < size < max
        for ($length = 2; $length < count($this->map); $length++) {
            foreach (combine_assoc($this->map, $length) as $map) {
                $combinations[] = new AssignmentCombination($map, $diff = array_sum($map) - $quantity);

                if ($diff == 0) {
                    return $combinations;
                }
            }
        }

        // Size max
        $combinations[] = new AssignmentCombination($this->map, array_sum($this->map) - $quantity);

        return $combinations;
    }
}

/**
 * Returns the unique combinations of the given values.
 *
 * @param array $values
 * @param int   $length
 *
 * @return \Generator
 */
function combine(array $values, $length)
{
    $originalLength = count($values);
    $remainingLength = $originalLength - $length + 1;
    for ($i = 0; $i < $remainingLength; ++$i) {
        $current = $values[$i];
        if (1 === $length) {
            yield [$current];
        } else {
            $remaining = array_slice($values, $i + 1);
            foreach (combine($remaining, $length - 1) as $permutation) {
                array_unshift($permutation, $current);
                yield $permutation;
            }
        }
    }
}

/**
 * Returns the unique combinations of the given values, preserving keys.
 *
 * @param array $values
 * @param int   $length
 *
 * @return \Generator
 */
function combine_assoc(array $values, $length)
{
    foreach (combine(array_keys($values), $length) as $combination) {
        $result = [];
        foreach ($combination as $key) {
            $result[$key] = $values[$key];
        }
        yield $result;
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Transformer;

use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Commerce\Payment\Model\PaymentInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Class SaleCopier
 * @package Ekyna\Component\Commerce\Common\Transformer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SaleCopier implements SaleCopierInterface
{
    /**
     * @var SaleFactoryInterface
     */
    protected $saleFactory;

    /**
     * @var Model\SaleInterface
     */
    protected $source;

    /**
     * @var Model\SaleInterface
     */
    protected $target;

    /**
     * @var PropertyAccessorInterface
     */
    protected $accessor;

    /**
     * Constructor.
     *
     * @param SaleFactoryInterface $saleFactory
     * @param Model\SaleInterface  $source
     * @param Model\SaleInterface  $target
     */
    public function __construct(
        SaleFactoryInterface $saleFactory,
        Model\SaleInterface $source,
        Model\SaleInterface $target
    ) {
        $this->saleFactory = $saleFactory;
        $this->source = $source;
        $this->target = $target;

        $this->accessor = PropertyAccess::createPropertyAccessor();
    }

    /**
     * @inheritdoc
     */
    public function copySale()
    {
        $this
            ->copyData()
            ->copyAddresses()
            ->copyAttachments()
            ->copyNotifications()
            ->copyItems()
            ->copyAdjustments()
            ->copyPayments();
    }

    /**
     * @inheritdoc
     */
    public function copyData()
    {
        $fields = [
            'currency', 'customer', 'customerGroup',
            'sameAddress', 'shipmentMethod', 'shipmentAmount', 'relayPoint',
            'autoShipping', 'autoDiscount', 'autoNotify', 'taxExempt', 'vatDisplayMode',
            'depositTotal', 'grandTotal', 'paymentTerm', 'outstandingDate', 'outstandingLimit',
            'title', 'voucherNumber', 'description', 'preparationNote', 'comment', 'documentComment',
            'exchangeRate', 'exchangeDate', 'locale', 'acceptedAt', 'source'
        ];

        // Copy information fields only if source has no customer entity
        if (null === $this->source->getCustomer()) {
            array_push($fields, 'email', 'company', 'gender', 'firstName', 'lastName');
        }

        $this->copy($this->source, $this->target, $fields);

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyAddresses()
    {
        // Invoice address
        if (null !== $sourceInvoiceAddress = $this->source->getInvoiceAddress()) {
            $targetInvoiceAddress = $this->saleFactory->createAddressForSale($this->target, $sourceInvoiceAddress);
            $this->target->setInvoiceAddress($targetInvoiceAddress);
        }

        // Delivery address
        if (null !== $sourceDeliveryAddress = $this->source->getDeliveryAddress()) {
            $targetDeliveryAddress = $this->saleFactory->createAddressForSale($this->target, $sourceDeliveryAddress);
            $this->target->setDeliveryAddress($targetDeliveryAddress);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyAdjustments()
    {
        foreach ($this->source->getAdjustments() as $sourceAdjustment) {
            $targetAdjustment = $this->saleFactory->createAdjustmentForSale($this->target);
            $this->target->addAdjustment($targetAdjustment);
            $this->copyAdjustment($sourceAdjustment, $targetAdjustment);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyAttachments()
    {
        foreach ($this->source->getAttachments() as $sourceAttachment) {
            $targetAttachment = $this->saleFactory->createAttachmentForSale($this->target);
            $this->target->addAttachment($targetAttachment);
            $this->copyAttachment($sourceAttachment, $targetAttachment);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyNotifications()
    {
        foreach ($this->source->getNotifications() as $sourceNotification) {
            $targetNotification = $this->saleFactory->createNotificationForSale($this->target);
            $this->target->addNotification($targetNotification);
            $this->copyNotification($sourceNotification, $targetNotification);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyItems()
    {
        foreach ($this->source->getItems() as $sourceItem) {
            $targetItem = $this->saleFactory->createItemForSale($this->target);
            $this->target->addItem($targetItem);
            $this->copyItem($sourceItem, $targetItem);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function copyPayments()
    {
        foreach ($this->source->getPayments() as $sourcePayment) {
            $targetPayment = $this->saleFactory->createPaymentForSale($this->target);
            $this->target->addPayment($targetPayment);
            $this->copyPayment($sourcePayment, $targetPayment);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    /*public function copyShipments()
    {
        if (!$this->source instanceof ShipmentSubjectInterface) {
            return $this;
        }
        if (!$this->target instanceof ShipmentSubjectInterface) {
            return $this;
        }

        foreach ($this->source->getShipments() as $sourceShipment) {
            $targetShipment = $this->saleFactory->createShipmentForSale($this->target);
            $this->target->addShipment($targetShipment);
            $this->copyShipment($sourceShipment, $targetShipment);
        }

        return $this;
    }*/

    /**
     * @inheritdoc
     */
    /*public function copyInvoices()
    {
        if (!$this->source instanceof InvoiceSubjectInterface) {
            return $this;
        }
        if (!$this->target instanceof InvoiceSubjectInterface) {
            return $this;
        }

        foreach ($this->source->getPayments() as $sourceInvoice) {
            $targetInvoice = $this->saleFactory->createInvoiceForSale($this->target);
            $this->target->addInvoice($targetInvoice);
            $this->copyInvoice($sourceInvoice, $targetInvoice);
        }

        return $this;
    }*/

    /**
     * Copies the source adjustment into the target adjustment.
     *
     * @param Model\AdjustmentInterface $source
     * @param Model\AdjustmentInterface $target
     */
    private function copyAdjustment(Model\AdjustmentInterface $source, Model\AdjustmentInterface $target)
    {
        $this->copy($source, $target, [
            'designation', 'type', 'mode', 'amount', 'immutable',
        ]);
    }

    /**
     * Copies the source attachment into the target attachment.
     *
     * @param Model\SaleAttachmentInterface $source
     * @param Model\SaleAttachmentInterface $target
     */
    private function copyAttachment(Model\SaleAttachmentInterface $source, Model\SaleAttachmentInterface $target)
    {
        $this->copy($source, $target, [
            'path', 'title', 'type', 'size', 'internal', 'createdAt', 'updatedAt',
        ]);
    }

    /**
     * Copies the source notification into the target notification.
     *
     * @param Model\SaleNotificationInterface $source
     * @param Model\SaleNotificationInterface $target
     */
    private function copyNotification(Model\SaleNotificationInterface $source, Model\SaleNotificationInterface $target)
    {
        $this->copy($source, $target, [
            'type', 'data', 'sentAt', 'details'
        ]);
    }

    /**
     * Copy the source item into the target item.
     *
     * @param Model\SaleItemInterface $source
     * @param Model\SaleItemInterface $target
     */
    private function copyItem(Model\SaleItemInterface $source, Model\SaleItemInterface $target)
    {
        $this->copy($source, $target, [
            'designation', 'description', 'reference', 'taxGroup', 'netPrice', 'weight', 'quantity',
            'position', 'compound', 'immutable', 'configurable', 'private', 'data',
        ]);

        // SubjectIdentity
        $this->copy($source->getSubjectIdentity(), $target->getSubjectIdentity(), [
            'provider', 'identifier',
        ]);

        // Adjustments
        foreach ($source->getAdjustments() as $sourceAdjustment) {
            $targetAdjustment = $this->saleFactory->createAdjustmentForItem($target);
            $target->addAdjustment($targetAdjustment);
            $this->copyAdjustment($sourceAdjustment, $targetAdjustment);
        }

        // Children
        foreach ($source->getChildren() as $sourceChild) {
            $targetChild = $this->saleFactory->createItemForSale($target->getSale());
            $target->addChild($targetChild);
            $this->copyItem($sourceChild, $targetChild);
        }
    }

    /**
     * Copy the source payment into the target payment.
     *
     * @param PaymentInterface $source
     * @param PaymentInterface $target
     */
    private function copyPayment(PaymentInterface $source, PaymentInterface $target)
    {
        $this->copy($source, $target, [
            'currency', 'method', 'key', 'number', 'amount', 'state', 'details', 'description',
            'createdAt', 'updatedAt', 'completedAt', 'exchangeRate', 'exchangeDate'
        ]);
    }

    /**
     * Copies the given properties from the source object to the target object.
     *
     * @param object $source
     * @param object $target
     * @param array $properties
     */
    private function copy($source, $target, array $properties)
    {
        $properties = (array)$properties;

        foreach ($properties as $property) {
            $this->accessor->setValue($target, $property, $this->accessor->getValue($source, $property));
        }
    }
}


<?php

namespace PeskyCMF\Scaffold\DataGrid;

use PeskyCMF\Scaffold\ScaffoldException;
use PeskyORM\Core\DbExpr;
use Swayok\Utils\NormalizeValue;

class ColumnFilter {

    const TYPE_STRING = 'string';
    const TYPE_INTEGER = 'integer';
    const TYPE_FLOAT = 'double';
    const TYPE_DATE = 'date';
    const TYPE_TIME = 'time';
    const TYPE_TIMESTAMP = 'datetime';
    const TYPE_BOOL = 'boolean';

    static protected $dataTypeDefaultOperatorsGroup = [
        self::TYPE_STRING => self::OPERATOR_GROUP_STRINGS,
        self::TYPE_INTEGER => self::OPERATOR_GROUP_NUMBERS,
        self::TYPE_FLOAT => self::OPERATOR_GROUP_NUMBERS,
        self::TYPE_DATE => self::OPERATOR_GROUP_TIMESTAMP,
        self::TYPE_TIME => self::OPERATOR_GROUP_TIMESTAMP,
        self::TYPE_TIMESTAMP => self::OPERATOR_GROUP_TIMESTAMP,
        self::TYPE_BOOL => self::OPERATOR_GROUP_BOOL,
    ];

    static protected $dataTypeToDefaultInputType = [
        self::TYPE_STRING => self::INPUT_TYPE_STRING,
        self::TYPE_INTEGER => self::INPUT_TYPE_STRING,
        self::TYPE_FLOAT => self::INPUT_TYPE_STRING,
        self::TYPE_DATE => self::INPUT_TYPE_STRING,
        self::TYPE_TIME => self::INPUT_TYPE_STRING,
        self::TYPE_TIMESTAMP => self::INPUT_TYPE_STRING,
        self::TYPE_BOOL => self::INPUT_TYPE_RADIO,
    ];

    const OPERATOR_GROUP_NUMBERS = 'numbers';
    const OPERATOR_GROUP_STRINGS = 'strings';
    const OPERATOR_GROUP_NULLS = 'nulls';
    const OPERATOR_GROUP_IN_ARRAY = 'in';
    const OPERATOR_GROUP_ONLY_EQUALS = 'equals';
    const OPERATOR_GROUP_TIMESTAMP = 'timestamp';
    const OPERATOR_GROUP_BOOL = 'boolean';
    const OPERATOR_GROUP_ALL = 'all';

    const OPERATOR_EQUAL = 'equal';
    const OPERATOR_NOT_EQUAL = 'not_equal';
    const OPERATOR_IN_ARRAY = 'in';
    const OPERATOR_NOT_IN_ARRAY = 'not_in';
    const OPERATOR_LESS = 'less';
    const OPERATOR_LESS_OR_EQUAL = 'less_or_equal';
    const OPERATOR_GREATER = 'greater';
    const OPERATOR_GREATER_OR_EQUAL = 'greater_or_equal';
    const OPERATOR_BETWEEN = 'between';
    const OPERATOR_NOT_BETWEEN = 'not_between';
    const OPERATOR_BEGINS_WITH = 'begins_with';
    const OPERATOR_NOT_BEGINS_WITH = 'not_begins_with';
    const OPERATOR_CONTAINS = 'contains';
    const OPERATOR_NOT_CONTAINS = 'not_contains';
    const OPERATOR_ENDS_WITH = 'ends_with';
    const OPERATOR_NOT_ENDS_WITH = 'not_ends_with';
    const OPERATOR_IS_EMPTY = 'is_empty';
    const OPERATOR_IS_NOT_EMPTY = 'is_not_empty';
    const OPERATOR_IS_NULL = 'is_null';
    const OPERATOR_IS_NOT_NULL = 'is_not_null';

    static protected $operatorGroups = [
        self::OPERATOR_GROUP_NULLS => [
            self::OPERATOR_IS_NULL,
            self::OPERATOR_IS_NOT_NULL,
        ],
        self::OPERATOR_GROUP_IN_ARRAY => [
            self::OPERATOR_IN_ARRAY,
            self::OPERATOR_NOT_IN_ARRAY,
        ],
        self::OPERATOR_GROUP_ONLY_EQUALS => [
            self::OPERATOR_EQUAL,
            self::OPERATOR_NOT_EQUAL,
        ],
        self::OPERATOR_GROUP_NUMBERS => [
            self::OPERATOR_EQUAL,
            self::OPERATOR_NOT_EQUAL,
            self::OPERATOR_IN_ARRAY,
            self::OPERATOR_NOT_IN_ARRAY,
            self::OPERATOR_LESS,
            self::OPERATOR_LESS_OR_EQUAL,
            self::OPERATOR_GREATER,
            self::OPERATOR_GREATER_OR_EQUAL,
            self::OPERATOR_BETWEEN,
            self::OPERATOR_NOT_BETWEEN,
        ],
        self::OPERATOR_GROUP_STRINGS => [
            self::OPERATOR_CONTAINS,
            self::OPERATOR_NOT_CONTAINS,
            self::OPERATOR_EQUAL,
            self::OPERATOR_NOT_EQUAL,
            self::OPERATOR_BEGINS_WITH,
            self::OPERATOR_NOT_BEGINS_WITH,
            self::OPERATOR_ENDS_WITH,
            self::OPERATOR_NOT_ENDS_WITH,
            self::OPERATOR_IS_EMPTY,
            self::OPERATOR_IS_NOT_EMPTY,
        ],
        self::OPERATOR_GROUP_TIMESTAMP => [
            self::OPERATOR_EQUAL,
            self::OPERATOR_NOT_EQUAL,
            self::OPERATOR_LESS,
            self::OPERATOR_LESS_OR_EQUAL,
            self::OPERATOR_GREATER,
            self::OPERATOR_GREATER_OR_EQUAL,
            self::OPERATOR_BETWEEN,
            self::OPERATOR_NOT_BETWEEN,
        ],
        self::OPERATOR_GROUP_BOOL => [
            self::OPERATOR_EQUAL,
        ],
        self::OPERATOR_GROUP_ALL => [
            self::OPERATOR_CONTAINS,
            self::OPERATOR_NOT_CONTAINS,
            self::OPERATOR_EQUAL,
            self::OPERATOR_NOT_EQUAL,
            self::OPERATOR_IN_ARRAY,
            self::OPERATOR_NOT_IN_ARRAY,
            self::OPERATOR_LESS,
            self::OPERATOR_LESS_OR_EQUAL,
            self::OPERATOR_GREATER,
            self::OPERATOR_GREATER_OR_EQUAL,
            self::OPERATOR_BETWEEN,
            self::OPERATOR_NOT_BETWEEN,
            self::OPERATOR_BEGINS_WITH,
            self::OPERATOR_NOT_BEGINS_WITH,
            self::OPERATOR_ENDS_WITH,
            self::OPERATOR_NOT_ENDS_WITH,
            self::OPERATOR_IS_EMPTY,
            self::OPERATOR_IS_NOT_EMPTY,
            self::OPERATOR_IS_NULL,
            self::OPERATOR_IS_NOT_NULL,
        ],
    ];

    static protected $ruleOperatorToDbOperator = [
        self::OPERATOR_EQUAL => '=',
        self::OPERATOR_NOT_EQUAL => '!=',
        self::OPERATOR_IN_ARRAY => 'IN',
        self::OPERATOR_NOT_IN_ARRAY => 'NOT IN',
        self::OPERATOR_LESS => '<',
        self::OPERATOR_LESS_OR_EQUAL => '<=',
        self::OPERATOR_GREATER => '>',
        self::OPERATOR_GREATER_OR_EQUAL => '>=',
        self::OPERATOR_BETWEEN => 'BETWEEN',
        self::OPERATOR_NOT_BETWEEN => 'NOT BETWEEN',
        self::OPERATOR_CONTAINS => '::text ~*',
        self::OPERATOR_NOT_CONTAINS => '::text !~*',
        self::OPERATOR_BEGINS_WITH => '::text ~*',
        self::OPERATOR_NOT_BEGINS_WITH => '::text !~*',
        self::OPERATOR_ENDS_WITH => '::text ~*',
        self::OPERATOR_NOT_ENDS_WITH => '::text !~*',
        self::OPERATOR_IS_EMPTY => '=',
        self::OPERATOR_IS_NOT_EMPTY => '!=',
        self::OPERATOR_IS_NULL => 'IS',
        self::OPERATOR_IS_NOT_NULL => 'IS NOT',
    ];

    const INPUT_TYPE_STRING = 'text';
    const INPUT_TYPE_TEXT = 'textarea';
    const INPUT_TYPE_RADIO = 'radio';
    const INPUT_TYPE_CHECKBOX = 'checkbox';
    const INPUT_TYPE_SELECT = 'select';
    const INPUT_TYPE_MULTISELECT = 'multselect';

    static protected $inputTypes = [
        self::INPUT_TYPE_STRING,
        self::INPUT_TYPE_TEXT,
        self::INPUT_TYPE_SELECT,
        self::INPUT_TYPE_MULTISELECT,
        self::INPUT_TYPE_CHECKBOX,
        self::INPUT_TYPE_RADIO,
    ];
    /** @var string|null */
    protected $columnName = null;
    /** @var string|null */
    protected $columnNameForTranslation;
    protected $filterLabel = null;
    protected $dataType = null;
    protected $inputType = null;
    protected $multiselect = false;
    /** @var null|array */
    protected $operators = null;
    protected $allowedValues = [
        //'value' => 'label'
    ];
    protected $plugin = null;
    protected $pluginConfig = [];
    // details: http://querybuilder.js.org/index.html#filters
    protected $otherSettings = [

    ];
    // details: http://querybuilder.js.org/index.html#validation
    protected $validators = [
        //'min' => 0,       //< numbers - min value, strings - min length, timestamps - min date/time/datetime in correct 'format'
        //'max' => 0,       //< numbers - max value, strings - max length, timestamps - max date/time/datetime in correct 'format'
        //'step' => 1,      //< for numbers
        //'format' => '',   //< regexp for strings or datetime format for timestamps (http://momentjs.com/docs/#/parsing/string-format/)
        //'allow_empty_value' => true,
    ];
    /** @var null|string|DbExpr */
    protected $columnNameReplacementForCondition = null;
    /** @var bool  */
    protected $nullable = false;
    /** @var null|FilterConfig */
    protected $filterConfig;
    /** @var null|\Closure */
    protected $incomingValueModifier;

    /**
     * @param string $dataType
     * @param bool $canBeNull
     * @param null|string $columnName
     * @return ColumnFilter
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     */
    static public function create($dataType = self::TYPE_STRING, $canBeNull = false, $columnName = null) {
        return new static($dataType, $canBeNull, $columnName);
    }

    /**
     * Configure for ID column (primary or foreign keys)
     * @param bool $excludeZero
     * @param bool $canBeNull
     * @param null|string $columnName
     * @return ColumnFilter
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     */
    static public function forPositiveInteger($excludeZero = false, $canBeNull = false, $columnName = null) {
        return static::create(static::TYPE_INTEGER, $canBeNull, $columnName)->setMin($excludeZero ? 1 : 0);
    }

    /**
     * ColumnFilter constructor.
     * @param string $dataType
     * @param bool $canBeNull
     * @param null $columnName
     * @throws ScaffoldException
     */
    public function __construct($dataType = self::TYPE_STRING, $canBeNull = false, $columnName = null) {
        if (!empty($columnName)) {
            $this->setColumnName($columnName);
        }
        $this->setDataType($dataType, $canBeNull);
    }

    /**
     * @param FilterConfig $config
     * @return $this
     */
    public function setFilterConfig(FilterConfig $config) {
        $this->filterConfig = $config;
        return $this;
    }

    /**
     * @return FilterConfig
     */
    public function getFilterConfig() {
        if (!$this->filterConfig) {
            throw new \BadMethodCallException('FilterConfig not set');
        }
        return $this->filterConfig;
    }

    /**
     * @param $operator
     * @return bool
     */
    static public function hasOperator($operator) {
        return in_array($operator, static::$operatorGroups[static::OPERATOR_GROUP_ALL], true);
    }

    /**
     * @return bool
     */
    public function hasColumnName() {
        return !empty($this->columnName);
    }

    /**
     * @param null $columnName
     * @return $this
     */
    public function setColumnName($columnName) {
        $this->columnName = $columnName;
        return $this;
    }

    /**
     * @return string
     * @throws ScaffoldException
     */
    public function getColumnName() {
        if (empty($this->columnName)) {
            throw new ScaffoldException('Column name is empty for this filter');
        }
        return $this->columnName;
    }

    /**
     * @param string $name
     * @return $this
     */
    public function setColumnNameForTranslation($name) {
        $this->columnNameForTranslation = $name;
        return $this;
    }

    /**
     * @return string
     */
    public function getColumnNameForTranslation() {
        if ($this->columnNameForTranslation === null) {
            $this->columnNameForTranslation = strtolower(trim(preg_replace(
                ['%([A-Z])%', '%[^a-zA-Z0-9.-]+%'],
                ['_$1', '_'],
                $this->columnName
            ), '_-.'));
        }
        return $this->columnNameForTranslation;
    }

    /**
     * @return string
     */
    public function getDataType() {
        return $this->dataType;
    }

    /**
     * @param string $type
     * @param bool $canBeNull
     * @return $this
     * @throws ScaffoldException
     */
    public function setDataType($type, $canBeNull = false) {
        if (!array_key_exists($type, static::$dataTypeDefaultOperatorsGroup)) {
            throw new ScaffoldException("Unknown filter type: $type");
        }
        $this->dataType = $type;
        if ($canBeNull) {
            $this->canBeNull();
        }
        $this->setInputType(static::$dataTypeToDefaultInputType[$type]);
        switch ($type) {
            case static::TYPE_BOOL:
                $this->setAllowedValues([
                    't' => cmfTransGeneral('.datagrid.filter.bool.yes'),
                    'f' => cmfTransGeneral('.datagrid.filter.bool.no'),
                ]);
                break;
            case static::TYPE_TIME:
                $this->setFormat('HH:mm');
                break;
            case static::TYPE_DATE:
            case static::TYPE_TIMESTAMP:
                $pluginConfig = [
                    'locale' => app()->getLocale(),
                    'sideBySide' => false,
                    'useCurrent' => false,
                    'toolbarPlacement' => 'bottom',
                    'showTodayButton' => true,
                    'showClear' => false,
                    'showClose' => true,
                    'keepOpen' => false,
                ];
                if ($type === static::TYPE_DATE) {
                    $this->setFormat('YYYY-MM-DD');
                } else {
                    $this->setFormat('YYYY-MM-DD HH:mm');
                    $pluginConfig['sideBySide'] = true;
                }
                $pluginConfig['format'] = $this->getFormat();
                $this->setPlugin('datetimepicker')
                    ->setPluginConfig($pluginConfig)
                    ->setOtherSettings(['input_event' => 'dp.change']);
                break;
        }
        return $this;
    }

    /**
     * Add [is null] and [is not null] operators
     * @return $this
     */
    public function canBeNull() {
        $this->nullable = true;
        return $this;
    }

    /**
     * @return array
     */
    public function getOperators() {
        if ($this->operators === null) {
            if ($this->getInputType() === static::INPUT_TYPE_SELECT) {
                $this->operators = $this->multiselect
                    ? static::$operatorGroups[static::OPERATOR_GROUP_IN_ARRAY]
                    : static::$operatorGroups[static::OPERATOR_GROUP_ONLY_EQUALS];
            } else {
                $this->operators = static::$operatorGroups[static::$dataTypeDefaultOperatorsGroup[$this->getDataType()]];
            }
            if ($this->nullable) {
                $this->operators = array_merge($this->operators, static::$operatorGroups[static::OPERATOR_GROUP_NULLS]);
            }
        }
        return $this->operators;
    }

    /**
     * @param array $operators
     * @return $this
     * @throws ScaffoldException
     */
    public function setOperators(array $operators) {
        foreach ($operators as $operator) {
            if (!in_array($operator, static::$operatorGroups[static::OPERATOR_GROUP_ALL], true)) {
                throw new ScaffoldException("Unknown filter operator: $operator");
            }
        }
        $this->operators = $operators;
        return $this;
    }

    /**
     * @param string $presetName - one of static::OPERATOR_GROUP_*
     * @return $this
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     */
    public function setOperatorsFromPreset($presetName) {
        if (!array_key_exists($presetName, static::$operatorGroups)) {
            throw new ScaffoldException("Unknown filter operators preset: $presetName");
        }
        $this->operators = static::$operatorGroups[$presetName];
        return $this;
    }

    /**
     * @return string
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     */
    public function getFilterLabel() {
        if (empty($this->filterLabel)) {
            $this->filterLabel = $this->getFilterConfig()->translate($this);
        }
        return $this->filterLabel;
    }

    /**
     * @param null $filterLabel
     * @return $this
     */
    public function setFilterLabel($filterLabel) {
        $this->filterLabel = $filterLabel;
        return $this;
    }

    /**
     * @return string
     */
    public function getInputType() {
        return $this->inputType;
    }

    /**
     * @param string $inputType
     * @return $this
     * @throws ScaffoldException
     */
    public function setInputType($inputType) {
        if (!in_array($inputType, static::$inputTypes, true)) {
            throw new ScaffoldException("Unknown filter input type: $inputType");
        }
        switch ($inputType) {
            case static::INPUT_TYPE_MULTISELECT:
                $inputType = static::INPUT_TYPE_SELECT;
                $this->multiselect = true;
                break;
        }
        $this->inputType = $inputType;
        return $this;
    }

    /**
     * @return array
     */
    public function getOtherSettings() {
        return $this->otherSettings;
    }

    /**
     * Other filter rule settings
     * Details: http://querybuilder.js.org/index.html#filters
     * @param array $otherSettings
     * @return $this
     */
    public function setOtherSettings(array $otherSettings) {
        $this->otherSettings = $otherSettings;
        return $this;
    }

    /**
     * @return array
     * @throws ScaffoldException
     */
    public function getAllowedValues() {
        if (empty($this->allowedValues) && $this->isItRequiresAllowedValues()) {
            throw new ScaffoldException('List of allowed values is empty');
        }
        return value($this->allowedValues);
    }

    /**
     * This filter has one of selection types (select, radio, checkbox) and require $this->allowedValues to be set
     * @return bool
     */
    protected function isItRequiresAllowedValues() {
        return in_array(
            $this->inputType,
            [static::INPUT_TYPE_SELECT, static::INPUT_TYPE_RADIO, static::INPUT_TYPE_CHECKBOX],
            true
        );
    }

    /**
     * @param array|\Closure $allowedValues
     * @return $this
     * @throws ScaffoldException
     */
    public function setAllowedValues($allowedValues) {
        if (!$this->isItRequiresAllowedValues()) {
            throw new ScaffoldException("Cannot set allowed values list to a filter input type: {$this->inputType}");
        } else if (empty($allowedValues)) {
            throw new ScaffoldException('List of allowed values is empty');
        } else if (!is_array($allowedValues) && !($allowedValues instanceof \Closure)) {
            throw new ScaffoldException('List of allowed values should be array or \Closure');
        }
        $this->allowedValues = $allowedValues;
        return $this;
    }

    /**
     * @return null
     */
    public function getPlugin() {
        return $this->plugin;
    }

    /**
     * @param null $plugin
     * @return $this
     */
    public function setPlugin($plugin) {
        $this->plugin = $plugin;
        return $this;
    }

    /**
     * @return array
     */
    public function getPluginConfig() {
        return $this->pluginConfig;
    }

    /**
     * @param array $pluginConfig
     * @return $this
     */
    public function setPluginConfig(array $pluginConfig) {
        $this->pluginConfig = $pluginConfig;
        return $this;
    }

    /**
     * @return array
     */
    public function getValidators() {
        return $this->validators;
    }

    /**
     * @param int|string $min -
     *      numbers: min value
     *      strings: min length
     *      timestamps: min date/time/datetime in correct 'format'
     * @return $this
     */
    public function setMin($min) {
        $this->validators['min'] = $min;
        return $this;
    }

    /**
     * @param int|string $max -
     *      numbers: max value
     *      strings: max length
     *      timestamps: max date/time/datetime in correct 'format'
     * @return $this
     */
    public function setMax($max) {
        $this->validators['max'] = $max;
        return $this;
    }

    /**
     * @param int|float $step - for numbers only
     * @return $this
     */
    public function setStep($step) {
        $this->validators['step'] = $step;
        return $this;
    }

    /**
     * @param string $format -
     *      strings: regexp
     *      timestamps: datetime format for timestamps in js (http://momentjs.com/docs/#/parsing/string-format/)
     * @return $this
     * @throws ScaffoldException
     */
    public function setFormat($format) {
        $this->validators['format'] = $format;
        return $this;
    }

    /**
     * @return null|string
     */
    public function getFormat() {
        return empty($this->validators['format']) ? null : $this->validators['format'];
    }

    /**
     * @return string|null|DbExpr
     */
    public function getColumnNameReplacementForCondition() {
        return $this->columnNameReplacementForCondition;
    }

    /**
     * @return bool
     */
    public function hasColumnNameReplacementForCondition() {
        return !empty($this->columnNameReplacementForCondition);
    }

    /**
     * Replace column's name when building a condition for DB
     * @param string|DbExpr $columnNameReplacementForCondition
     * Example:
     *  without replacement: for column_name = 'count', operation = "equals", value = "1" conditon will be: "count" = '1'
     *  with replacement: expression = DbExpr('COALESCE(`count`, ``0``)') condition will be COALESCE("count", 0) = '1'
     * @return $this
     */
    public function setColumnNameReplacementForCondition($columnNameReplacementForCondition) {
        $this->columnNameReplacementForCondition = $columnNameReplacementForCondition;
        return $this;
    }

    /**
     * @param \Closure $modifier - function ($value, $operator, ColumnFilter $columnFilter) { return $value; }
     * @return $this
     */
    public function setIncomingValueModifier(\Closure $modifier) {
        $this->incomingValueModifier = $modifier;
        return $this;
    }

    /**
     * @param mixed $value
     * @param string $operator
     * @return mixed
     */
    protected function modifyIncomingValue($value, $operator) {
        if ($this->incomingValueModifier) {
            return call_user_func($this->incomingValueModifier, $value, $operator, $this);
        }
        return $value;
    }

    /**
     * @return array
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     */
    public function buildConfig() {
        return array_merge([
            'id' => static::buildFilterId($this->getColumnName()),
            'field' => $this->getColumnName(),
            'label' => $this->getFilterLabel(),
            'type' => $this->getDataType(),
            'input' => $this->getInputType(),
            'values' => $this->getAllowedValues(),
            'multiple' => $this->multiselect,
            'validation' => $this->getValidators(),
            'operators' => $this->getOperators(),
            'plugin' => $this->getPlugin(),
            'plugin_config' => $this->getPluginConfig()
        ], $this->otherSettings);
    }

    /**
     * @param $columnName
     * @return string
     */
    static public function buildFilterId($columnName) {
        return 'filter-for-' . strtolower(preg_replace('%[^a-zA-Z0-9]+%i', '-', $columnName));
    }

    /**
     * @param string $operator
     * @param mixed $value
     * @return array
     * @throws ScaffoldException
     */
    public function buildConditionFromSearchRule($operator, $value) {
        if (!in_array($operator, $this->getOperators(), true)) {
            throw new ScaffoldException("Operator [$operator] is forbidden for filter [{$this->getColumnName()}]");
        }
        if (!is_array($value)) {
            $value = trim($value);
        }
         // resolve multivalues
        switch ($operator) {
            case static::OPERATOR_IN_ARRAY:
            case static::OPERATOR_NOT_IN_ARRAY:
                $value = preg_split('%\s*,\s*%', $value);
                break;
        }
        $value = $this->modifyIncomingValue($value, $operator);
        $this->validateValue($value, $operator);
        $value = $this->convertRuleValueToConditionValue($value, $operator);
        $dbOperator = $this->convertRuleOperatorToDbOperator($operator);
        $dataTypeConverter = $this->getValueDataTypeConverterForDb();
        // resolve column name replacement (it could be a DbExpr that concatenates many columns)
        if ($this->hasColumnNameReplacementForCondition()) {
            $colReplacement = $this->getColumnNameReplacementForCondition();
            if ($colReplacement instanceof DbExpr) {
                switch ($operator) {
                    case static::OPERATOR_IN_ARRAY:
                    case static::OPERATOR_NOT_IN_ARRAY:
                        $value = '(``' . implode('``,``', $value) . '``)';
                        break;
                    case static::OPERATOR_BETWEEN:
                    case static::OPERATOR_NOT_BETWEEN:
                        $value = "``{$value[0]}`` AND ``{$value[1]}``";
                        break;
                    case static::OPERATOR_IS_NULL:
                    case static::OPERATOR_IS_NOT_NULL:
                        $value = 'NULL';
                        break;
                    default:
                        $value = "``{$value}``";
                }
                return [DbExpr::create($colReplacement->get() . " {$dbOperator} {$value}")];
            } else {
                $columnName = $colReplacement;
            }
        } else {
            $columnName = $this->getColumnName();
        }
        return [trim($columnName . $dataTypeConverter . ' ' . $dbOperator) => $value];
    }

    /**
     * @param mixed $value
     * @param $operator
     * @throws ScaffoldException
     */
    protected function validateValue($value, $operator) {
        if (
            ($value === null || $value === '')
            && in_array(
                $operator,
                [static::OPERATOR_IS_NULL, static::OPERATOR_IS_NOT_NULL, static::OPERATOR_IS_NOT_EMPTY, static::OPERATOR_IS_EMPTY],
                true
            )
        ) {
            return; //< no value needed
        }
        if (is_array($value)) {
            foreach ($value as $i => &$val) {
                if (empty($val)) {
                    unset($value[$i]);
                } else {
                    $this->validateValue(trim($val), null);
                }
            }
            unset($val);
            if (empty($value)) {
                throw new ScaffoldException("Empty filter value is not allowed for [$operator] operator");
            }
            if (count($value) !== 2 && in_array($operator, [static::OPERATOR_BETWEEN, static::OPERATOR_NOT_BETWEEN], true)) {
                throw new ScaffoldException("There should be exactly 2 filter values for [$operator] operator");
            }
            return;
        }
        $validatorRule = 'required';
        $validators = $this->getValidators();
        if (!empty($validators['min'])) {
            $validatorRule .= '|min:' . $validators['min'];
        }
        if (!empty($validators['max'])) {
            $validatorRule .= '|max:' . $validators['max'];
        }
        switch ($this->getDataType()) {
            case static::TYPE_INTEGER:
                $validatorRule .= '|integer';
                break;
            case static::TYPE_BOOL:
                $validatorRule .= '|in:t,f,0,1';
                break;
            case static::TYPE_FLOAT:
                $validatorRule .= '|numeric';
                break;
            case static::TYPE_DATE:
            case static::TYPE_TIME:
            case static::TYPE_TIMESTAMP:
                $validatorRule .= '|date';
                break;
            case static::TYPE_STRING:
                if (!empty($validators['format'])) {
                    $validatorRule .= '|regex:' . $validators['format'];
                }
                break;
        }
        $validator = \Validator::make(['value' => $value], ['value' => $validatorRule]);
        if ($validator->fails()) {
            throw new ScaffoldException("Invalid value [$value] passed for filter column [{$this->getColumnName()}]");
        }
    }

    /**
     * @param mixed $value
     * @param string $operator
     * @return mixed
     */
    protected function convertRuleValueToConditionValue($value, $operator) {
        if (is_array($value)) {
            foreach ($value as &$val) {
                $val = trim($this->convertRuleValueToConditionValue($val, null));
            }
            return array_values($value);
        }
        switch ($this->getDataType()) {
            case static::TYPE_TIME:
                $value = NormalizeValue::normalizeTime($value);
                break;
            case static::TYPE_DATE:
                $value = NormalizeValue::normalizeDate($value);
                break;
            case static::TYPE_TIMESTAMP:
                $value = NormalizeValue::normalizeDateTime($value);
                break;
            case static::TYPE_INTEGER:
                $value = NormalizeValue::normalizeInteger($value);
                break;
            case static::TYPE_FLOAT:
                $value = NormalizeValue::normalizeFloat($value);
                break;
            case static::TYPE_BOOL:
                $value = NormalizeValue::normalizeBooleanExtended($value, ['f']);
                break;
        }
        switch ($operator) {
            case static::OPERATOR_BEGINS_WITH:
            case static::OPERATOR_NOT_BEGINS_WITH:
                return '^' . preg_quote($value, null);
                break;
            case static::OPERATOR_ENDS_WITH:
            case static::OPERATOR_NOT_ENDS_WITH:
                return preg_quote($value, null) . '$';
                break;
            case static::OPERATOR_CONTAINS:
            case static::OPERATOR_NOT_CONTAINS:
                return preg_quote($value, null);
                break;
            case static::OPERATOR_EQUAL:
            case static::OPERATOR_NOT_EQUAL:
                if ($this->getDataType() === static::TYPE_STRING && $this->getInputType() !== static::INPUT_TYPE_SELECT) {
                    return '^' . preg_quote($value, null) . '$';
                }
                break;
            case static::OPERATOR_IS_NULL:
            case static::OPERATOR_IS_NOT_NULL:
                return null;
            case static::OPERATOR_IS_EMPTY:
            case static::OPERATOR_IS_NOT_EMPTY:
                return '';
        }
        return $value;
    }

    /**
     * @param string $operator
     * @return string
     */
    protected function convertRuleOperatorToDbOperator($operator) {
        switch ($operator) {
            case static::OPERATOR_EQUAL:
            case static::OPERATOR_IN_ARRAY:
                if ($this->getDataType() === static::TYPE_STRING) {
                    return static::$ruleOperatorToDbOperator[static::OPERATOR_CONTAINS]; //< for case-insensitive search
                }
                break;
            case static::OPERATOR_NOT_EQUAL:
            case static::OPERATOR_NOT_IN_ARRAY:
                if ($this->getDataType() === static::TYPE_STRING) {
                    return static::$ruleOperatorToDbOperator[static::OPERATOR_NOT_CONTAINS]; //< for case-insensitive search
                }
                break;
        }
        return static::$ruleOperatorToDbOperator[$operator];
    }

    /**
     * Get forced data type converter for column and value in DB. For example for dates it will return '::date'
     * @return string
     */
    protected function getValueDataTypeConverterForDb() {
        switch ($this->getDataType()) {
            case static::TYPE_TIME:
                return '::time';
            case static::TYPE_DATE:
            case static::TYPE_TIMESTAMP:
                return '::date';
        }
        return '';
    }

}


<?php

namespace Ekyna\Component\Commerce\Order\EventListener;

use Ekyna\Component\Commerce\Common\Calculator\MarginCalculatorInterface;
use Ekyna\Component\Commerce\Common\EventListener\AbstractSaleListener;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Common\Model\SaleItemInterface;
use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Order\Event\OrderEvents;
use Ekyna\Component\Commerce\Order\Model\OrderInterface;
use Ekyna\Component\Commerce\Order\Model\OrderStates;
use Ekyna\Component\Commerce\Order\Repository\OrderRepositoryInterface;
use Ekyna\Component\Commerce\Stock\Assigner\StockUnitAssignerInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class OrderEventSubscriber
 * @package Ekyna\Component\Commerce\Order\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class OrderListener extends AbstractSaleListener
{
    /**
     * @var StockUnitAssignerInterface
     */
    protected $stockAssigner;

    /**
     * @var MarginCalculatorInterface
     */
    protected $marginCalculator;

    /**
     * @var OrderRepositoryInterface
     */
    protected $orderRepository;

    /**
     * Sets the stock assigner.
     *
     * @param StockUnitAssignerInterface $assigner
     */
    public function setStockAssigner(StockUnitAssignerInterface $assigner)
    {
        $this->stockAssigner = $assigner;
    }

    /**
     * Sets the margin calculator.
     *
     * @param MarginCalculatorInterface $calculator
     */
    public function setMarginCalculator(MarginCalculatorInterface $calculator)
    {
        $this->marginCalculator = $calculator;
    }

    /**
     * Sets the order repository.
     *
     * @param OrderRepositoryInterface $repository
     */
    public function setOrderRepository(OrderRepositoryInterface $repository)
    {
        $this->orderRepository = $repository;
    }

    /**
     * Prepare event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onPrepare(ResourceEventInterface $event)
    {
        $order = $this->getSaleFromEvent($event);

        if (!OrderStates::isStockableState($order->getState())) {
            throw new IllegalOperationException(
                "Order is not ready for shipment preparation"
            );
        }
    }

    /**
     * @inheritdoc
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        parent::onPreDelete($event);

        $order = $this->getSaleFromEvent($event);

        // Stop if order has invoices or shipments
        if ($order->hasInvoices() || $order->hasShipments()) {
            throw new IllegalOperationException(
                "Order with invoices or shipments can't be deleted."
            );
        }
    }

    /**
     * @inheritDoc
     */
    public function onPreUpdate(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if ($sale->isSample() && ($sale->hasPayments() || $sale->hasInvoices())) {
            throw new IllegalOperationException( // TODO Translation
                "Order with payments or invoices can't be turned into sample order."
            );
        }
    }

    /**
     * @inheritDoc
     *
     * @param OrderInterface $sale
     */
    protected function handleInsert(SaleInterface $sale)
    {
        $changed = $this->fixCustomers($sale);

        $changed |= $this->setIsFirst($sale);

        $changed |= parent::handleInsert($sale);

        $changed |= $this->updateExchangeRate($sale);

        $changed |= $this->updateLocale($sale);

        return $changed;
    }

    /**
     * @inheritDoc
     *
     * @param OrderInterface $sale
     */
    protected function handleUpdate(SaleInterface $sale)
    {
        $changed = $this->fixCustomers($sale);

        $changed |= parent::handleUpdate($sale);

        $changed |= $this->handleReleasedChange($sale);

        return $changed;
    }

    /**
     * Handles the released flag change.
     *
     * @param OrderInterface $order
     *
     * @return bool
     */
    public function handleReleasedChange(OrderInterface $order)
    {
        if ($this->persistenceHelper->isChanged($order , 'sample')) {
            if ($order->isReleased() && !$order->isSample()) {
                throw new IllegalOperationException("Can't turn 'sample' into false if order is released.");
            }
        }

        if (!$this->persistenceHelper->isChanged($order , 'released')) {
            return false;
        }

        // Orders that are not samples can't be released.
        if (!$order->isSample() && $order->isReleased()) {
            $order->setReleased(false);
            return true;
        }

        if (!OrderStates::isStockableState($order->getState())) {
            return false;
        }

        foreach ($order->getItems() as $item) {
            $this->applySaleItemRecursively($item);
        }

        return false;
    }

    /**
     * Sets whether this order is the customer's first one.
     *
     * @param OrderInterface $order
     *
     * @return bool Whether the order has been changed.
     */
    protected function setIsFirst(OrderInterface $order)
    {
        if (null !== $customer = $order->getCustomer()) {
            if ($customer->hasParent()) {
                $customer = $customer->getParent();
            }
            $first = !$this->orderRepository->existsForCustomer($customer);
        } else {
            $first = !$this->orderRepository->existsForEmail($order->getEmail());
        }

        if ($first != $order->isFirst()) {
            $order->setFirst($first);

            return true;
        }

        return false;
    }

    /**
     * Changes the customer and origin customer regarding to their hierarchy.
     *
     * @param OrderInterface $order
     *
     * @return bool
     */
    protected function fixCustomers(OrderInterface $order)
    {
        $changed = false;

        $originCustomer = $order->getOriginCustomer();
        $customer = $order->getCustomer();

        if (is_null($customer)) {
            if ($originCustomer && $originCustomer->hasParent()) {
                $order->setCustomer($originCustomer->getParent());

                $changed = true;
            }
        } elseif ($customer->hasParent()) {
            $order->setCustomer($customer->getParent());

            if (null === $order->getOriginCustomer()) {
                $order->setOriginCustomer($customer);
            }

            $changed = true;
        }

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($order, false);
        }

        return $changed;
    }

    /**
     * @inheritDoc
     *
     * @param OrderInterface $sale
     */
    protected function handleStateChange(SaleInterface $sale)
    {
        parent::handleStateChange($sale);

        if ($this->persistenceHelper->isChanged($sale, 'state')) {
            $stateCs = $this->persistenceHelper->getChangeSet($sale, 'state');

            // If order state has changed from non stockable to stockable
            if (OrderStates::hasChangedToStockable($stateCs)) {
                foreach ($sale->getItems() as $item) {
                    $this->assignSaleItemRecursively($item);
                }
            }
            // If order state has changed from stockable to non stockable
            elseif (OrderStates::hasChangedFromStockable($stateCs)) {
                foreach ($sale->getItems() as $item) {
                    $this->detachSaleItemRecursively($item);
                }
                // We don't need to handle invoices as they are detached with sale items.
            }
        }
    }

    /**
     * @inheritDoc
     *
     * @param OrderInterface $sale
     */
    protected function handleContentChange(SaleInterface $sale)
    {
        $changed = parent::handleContentChange($sale);

        if (null !== $margin = $this->marginCalculator->calculateSale($sale)) {
            $amount = $margin->getAmount();
        } else {
            $amount = 0;
        }

        if ($sale->getMarginTotal() != $amount) {
            $sale->setMarginTotal($amount);
            $changed = true;
        }

        if ($sale->getItemsCount() != $count = $sale->getItems()->count()) {
            $sale->setItemsCount($count);
            $changed = true;
        }

        return $changed;
    }

    /**
     * @inheritDoc
     */
    protected function isDiscountUpdateNeeded(SaleInterface $sale)
    {
        if ($this->persistenceHelper->isChanged($sale, 'sample')) {
            return true;
        }

        return parent::isDiscountUpdateNeeded($sale);
    }

    /**
     * @inheritDoc
     */
    protected function isTaxationUpdateNeeded(SaleInterface $sale)
    {
        if ($this->persistenceHelper->isChanged($sale, 'sample')) {
            return true;
        }

        return parent::isTaxationUpdateNeeded($sale);
    }

    /**
     * @inheritDoc
     */
    protected function isShipmentTaxationUpdateNeeded(SaleInterface $sale)
    {
        if ($this->persistenceHelper->isChanged($sale, 'sample')) {
            return true;
        }

        return parent::isShipmentTaxationUpdateNeeded($sale);
    }

    /**
     * @inheritDoc
     *
     * @param OrderInterface $sale
     */
    protected function updateVatDisplayMode(SaleInterface $sale)
    {
        // Vat display mode must not change if order has shipments or invoices.
        if ($sale->hasShipments() || $sale->hasInvoices()) {
            return false;
        }

        return parent::updateVatDisplayMode($sale);
    }

    /**
     * @inheritdoc
     *
     * @param OrderInterface $sale
     */
    protected function updateState(SaleInterface $sale)
    {
        if (parent::updateState($sale)) {
            if (in_array($sale->getState(), OrderStates::getStockableStates(), true)) {
                if (($sale->getState() === OrderStates::STATE_COMPLETED) && (null === $sale->getCompletedAt())) {
                    $sale->setCompletedAt(new \DateTime());
                } elseif (($sale->getState() !== OrderStates::STATE_COMPLETED) && (null !== $sale->getCompletedAt())) {
                    $sale->setCompletedAt(null);
                }

                if (null === $sale->getAcceptedAt()) {
                    $sale->setAcceptedAt(new \DateTime());
                }
            } else {
                $sale
                    ->setAcceptedAt(null)
                    ->setCompletedAt(null);
            }

            return true;
        }

        return false;
    }

    /**
     * Assigns the sale item to stock units recursively.
     *
     * @param SaleItemInterface $item
     */
    protected function assignSaleItemRecursively(SaleItemInterface $item)
    {
        $this->stockAssigner->assignSaleItem($item);

        foreach ($item->getChildren() as $child) {
            $this->assignSaleItemRecursively($child);
        }
    }

    /**
     * Applies the sale item to stock units recursively.
     *
     * @param SaleItemInterface $item
     */
    protected function applySaleItemRecursively(SaleItemInterface $item)
    {
        $this->stockAssigner->applySaleItem($item);

        foreach ($item->getChildren() as $child) {
            $this->applySaleItemRecursively($child);
        }
    }

    /**
     * Detaches the sale item from stock units recursively.
     *
     * @param SaleItemInterface $item
     */
    protected function detachSaleItemRecursively(SaleItemInterface $item)
    {
        $this->stockAssigner->detachSaleItem($item);

        foreach ($item->getChildren() as $child) {
            $this->detachSaleItemRecursively($child);
        }
    }

    /**
     * @inheritdoc
     *
     * @return OrderInterface
     */
    protected function getSaleFromEvent(ResourceEventInterface $event)
    {
        $resource = $event->getResource();

        if (!$resource instanceof OrderInterface) {
            throw new InvalidArgumentException("Expected instance of OrderInterface");
        }

        return $resource;
    }

    /**
     * @inheritdoc
     */
    protected function scheduleContentChangeEvent(SaleInterface $sale)
    {
        if (!$sale instanceof OrderInterface) {
            throw new InvalidArgumentException("Expected instance of OrderInterface");
        }

        $this->persistenceHelper->scheduleEvent(OrderEvents::CONTENT_CHANGE, $sale);
    }

    /**
     * @inheritdoc
     */
    protected function scheduleStateChangeEvent(SaleInterface $sale)
    {
        if (!$sale instanceof OrderInterface) {
            throw new InvalidArgumentException("Expected instance of OrderInterface");
        }

        $this->persistenceHelper->scheduleEvent(OrderEvents::STATE_CHANGE, $sale);
    }
}