<?php

namespace Ekyna\Component\Commerce\Shipment\Builder;

use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Document\Calculator\DocumentCalculatorInterface;
use Ekyna\Component\Commerce\Document\Model as Document;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Invoice\Builder\InvoiceBuilderInterface;
use Ekyna\Component\Commerce\Invoice\Model as Invoice;
use Ekyna\Component\Commerce\Shipment\Model as Shipment;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class InvoiceSynchronizer
 * @package Ekyna\Component\Commerce\Shipment\Builder
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class InvoiceSynchronizer implements InvoiceSynchronizerInterface
{
    /**
     * @var InvoiceBuilderInterface
     */
    protected $invoiceBuilder;

    /**
     * @var DocumentCalculatorInterface
     */
    protected $documentCalculator;

    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * Constructor.
     *
     * @param InvoiceBuilderInterface     $invoiceBuilder
     * @param DocumentCalculatorInterface $documentCalculator
     * @param PersistenceHelperInterface  $persistenceHelper
     */
    public function __construct(
        InvoiceBuilderInterface $invoiceBuilder,
        DocumentCalculatorInterface $documentCalculator,
        PersistenceHelperInterface $persistenceHelper
    ) {
        $this->invoiceBuilder = $invoiceBuilder;
        $this->documentCalculator = $documentCalculator;
        $this->persistenceHelper = $persistenceHelper;
    }

    /**
     * @inheritdoc
     */
    public function synchronize(Shipment\ShipmentInterface $shipment)
    {
        // Abort if auto invoicing is disabled
        // (We do not remove linked invoice)
        if (!$shipment->isAutoInvoice()) {
            return;
        }

        // Shipment sale must be set
        /** @var Invoice\InvoiceSubjectInterface|Common\SaleInterface $sale */
        if (null === $sale = $shipment->getSale()) {
            throw new LogicException("Shipment's sale must be set at this point.");
        }

        $invoice = $shipment->getInvoice();

        // Abort if sale is sample, shipment is removed or shipment not in stockable state
        if (
            $sale->isSample() ||
            $this->persistenceHelper->isScheduledForRemove($shipment) ||
            !Shipment\ShipmentStates::isStockableState($shipment->getState())
        ) {
            if (null !== $invoice) {
                $this->removeInvoice($invoice);
            }

            return;
        }

        if (null === $invoice) {
            $invoice = $this->invoiceBuilder->getSaleFactory()->createInvoiceForSale($sale);
            $invoice
                ->setSale($sale)
                ->setShipment($shipment);

            if ($shipment->isReturn()) {
                if (null === $method = $shipment->getCreditMethod()) {
                    throw new RuntimeException("Return's credit method must be set at this point.");
                }

                $invoice
                    ->setType(Invoice\InvoiceTypes::TYPE_CREDIT)
                    ->setPaymentMethod($method);
            }
        }

        $this->checkShipmentInvoice($invoice);

        $changed = $this->purgeShipmentInvoice($invoice);

        $changed |= $this->feedShipmentInvoice($invoice);

        // Remove (and don't persist) empty invoices
        if (0 == count($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_GOOD))) {
            $this->removeInvoice($invoice);

            return;
        }

        if ($changed) {
            $this->persistInvoice($invoice);
        }
    }

    /**
     * Persists the invoice.
     *
     * @param Invoice\InvoiceInterface $invoice
     */
    private function persistInvoice(Invoice\InvoiceInterface $invoice)
    {
        $this->persistenceHelper->persistAndRecompute($invoice, true);

        foreach ($invoice->getLines() as $line) {
            $this->persistenceHelper->persistAndRecompute($line, true);
        }

        // Persist the shipment <-> invoice relation, without scheduling event.
        $this->persistenceHelper->persistAndRecompute($invoice->getShipment(), false);
    }

    /**
     * Removes the invoice.
     *
     * @param Invoice\InvoiceInterface $invoice
     */
    private function removeInvoice(Invoice\InvoiceInterface $invoice)
    {
        $invoice->setShipment(null);
        $invoice->setSale(null);

        if (null !== $invoice->getId()) {
            foreach ($invoice->getLines() as $line) {
                $this->persistenceHelper->remove($line, true);
            }
            $this->persistenceHelper->remove($invoice, true);
        } elseif ($this->persistenceHelper->isScheduledForInsert($invoice)) {
            $this->persistenceHelper->getManager()->remove($invoice);
        }
    }

    /**
     * Checks that the given invoice is correctly configured for shipment synchronization.
     *
     * @param Invoice\InvoiceInterface $invoice
     *
     * @throws LogicException
     */
    private function checkShipmentInvoice(Invoice\InvoiceInterface $invoice)
    {
        if (null === $shipment = $invoice->getShipment()) {
            throw new LogicException("Invoice's shipment must be set at this point.");
        }

        // Check sale integrity
        if ($shipment->getSale() !== $sale = $invoice->getSale()) {
            throw new LogicException("Shipment/Invoice sale miss match.");
        }

        // Sale must be a invoice subject
        if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
            throw new LogicException("Expected instance of " . Invoice\InvoiceSubjectInterface::class);
        }

        // Check shipment/invoice types integrity.
        if ($shipment->isReturn() && !Invoice\InvoiceTypes::isCredit($invoice)) {
            throw new LogicException("Invoice should not be associated with Return.");
        } elseif (!$shipment->isReturn() && !Invoice\InvoiceTypes::isInvoice($invoice)) {
            throw new LogicException("Credit should not be associated with Shipment.");
        }
    }

    /**
     * Removes the invoice unexpected lines regarding to his associated shipment.
     *
     * @param Invoice\InvoiceInterface $invoice
     *
     * @return bool Whether the invoice has been changed
     */
    private function purgeShipmentInvoice(Invoice\InvoiceInterface $invoice)
    {
        $changed = false;

        $shipment = $invoice->getShipment();

        // Remove unexpected good lines
        /** @var Invoice\InvoiceLineInterface $line */
        foreach ($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_GOOD) as $line) {
            foreach ($shipment->getItems() as $shipmentItem) {
                if ($line->getSaleItem() === $shipmentItem->getSaleItem()) {
                    continue 2; // Shipment item found -> next invoice line
                }
            }

            // Shipment item not found -> remove line
            $invoice->removeLine($line);

            $this->persistenceHelper->remove($line, false);

            $changed = true;
        }

        $sale = $invoice->getSale();

        // Remove unexpected discount lines
        foreach ($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_DISCOUNT) as $line) {
            foreach ($sale->getAdjustments(Common\AdjustmentTypes::TYPE_DISCOUNT) as $saleAdjustment) {
                if ($line->getSaleAdjustment() === $saleAdjustment) {
                    continue 2; // Sale adjustment found -> next invoice line
                }
            }

            // Sale adjustment not found -> remove line
            $invoice->removeLine($line);

            $this->persistenceHelper->remove($line, false);

            $changed = true;
        }

        // Remove unexpected shipment lines
        if (null === $sale->getShipmentMethod()) {
            foreach ($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_SHIPMENT) as $line) {
                $invoice->removeLine($line);

                $this->persistenceHelper->remove($line, false);

                $changed = true;
            }
        }

        return $changed;
    }

    /**
     * Creates invoice expected lines regarding to his associated shipment.
     *
     * @param Invoice\InvoiceInterface $invoice
     *
     * @return bool Whether the invoice has been changed
     */
    private function feedShipmentInvoice(Invoice\InvoiceInterface $invoice)
    {
        $changed = false;

        $shipment = $invoice->getShipment();

        $calculator = $this->invoiceBuilder->getInvoiceCalculator();

        // Add expected good lines
        /** @var Invoice\InvoiceLineInterface $line */
        foreach ($shipment->getItems() as $shipmentItem) {
            $saleItem = $shipmentItem->getSaleItem();

            $max = $shipment->isReturn()
                ? $calculator->calculateCreditableQuantity($saleItem)
                : $calculator->calculateInvoiceableQuantity($saleItem);

            if (0 < $quantity = min($max, $shipmentItem->getQuantity())) {
                $line = $this->invoiceBuilder->findOrCreateGoodLine($invoice, $saleItem, $max);
                if ($line->getQuantity() !== $quantity) {
                    $line->setQuantity($quantity);
                    $changed = true;
                }
            } /*else {
                // TODO find and remove line ?
                $invoice->removeLine($line);
                $changed = true;
            }*/
        }

        if ($invoice->hasLineByType(Document\DocumentLineTypes::TYPE_GOOD)) {
            // Add expected discount lines
            $sale = $invoice->getSale();
            foreach ($sale->getAdjustments(Common\AdjustmentTypes::TYPE_DISCOUNT) as $saleAdjustment) {
                foreach ($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_DISCOUNT) as $line) {
                    if ($saleAdjustment === $line->getSaleAdjustment()) {
                        continue 2; // Invoice line found -> next sale adjustment
                    }
                }

                // Invoice line not found -> create it
                $this->invoiceBuilder->buildDiscountLine($saleAdjustment, $invoice);

                $changed = true;
            }

            // Add expected shipment line
            if (null !== $sale->getShipmentMethod() && !$this->isShipmentAmountInvoiced($invoice)) {
                $this->invoiceBuilder->buildShipmentLine($invoice);
            }
        }

        return $changed;
    }

    /**
     * Returns whether the shipment amount is already invoiced.
     *
     * @param Invoice\InvoiceInterface $invoice
     *
     * @return bool
     */
    private function isShipmentAmountInvoiced(Invoice\InvoiceInterface $invoice)
    {
        /** @var Invoice\InvoiceSubjectInterface $sale */
        $sale = $invoice->getSale();

        // Abort if another invoice has a shipment line
        foreach ($sale->getInvoices() as $i) {
            if ($i === $invoice) {
                continue;
            }
            if ($i->hasLineByType(Document\DocumentLineTypes::TYPE_SHIPMENT)) {
                return true;
            }
        }

        return false;
    }
}


<?php

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

use Doctrine\DBAL\Types\Type;
use Ekyna\Component\Commerce\Customer\Model\CustomerInterface;
use Ekyna\Component\Commerce\Order\Model\OrderInvoiceInterface;
use Ekyna\Component\Commerce\Order\Repository\OrderInvoiceRepositoryInterface;
use Ekyna\Component\Resource\Doctrine\ORM\ResourceRepository;

/**
 * Class OrderInvoiceRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class OrderInvoiceRepository extends ResourceRepository implements OrderInvoiceRepositoryInterface
{
    /**
     * @inheritdoc
     *
     * @return OrderInvoiceInterface[]
     */
    public function findByCustomer(CustomerInterface $customer, $limit = null)
    {
        $qb = $this->createQueryBuilder('i');

        $qb
            ->join('i.order', 'o')
            ->andWhere($qb->expr()->eq('o.customer', ':customer'))
            ->andWhere($qb->expr()->eq('o.sample', ':sample'))
            ->addOrderBy('i.createdAt', 'ASC');

        if (0 < $limit) {
            $qb->setMaxResults($limit);
        }

        return $qb
            ->getQuery()
            ->setParameter('customer', $customer)
            ->setParameter('sample', false)
            ->getResult();
    }

    /**
     * @inheritDoc
     *
     * @return OrderInvoiceInterface
     */
    public function findOneByCustomerAndNumber(CustomerInterface $customer, $number)
    {
        $qb = $this->createQueryBuilder('i');

        return $qb
            ->join('i.order', 'o')
            ->andWhere($qb->expr()->eq('o.customer', ':customer'))
            ->andWhere($qb->expr()->eq('o.sample', ':sample'))
            ->andWhere($qb->expr()->eq('i.number', ':number'))
            ->addOrderBy('i.createdAt', 'ASC')
            ->getQuery()
            ->setParameter('customer', $customer)
            ->setParameter('sample', false)
            ->setParameter('number', $number)
            ->getOneOrNullResult();
    }

    /**
     * @inheritDoc
     */
    public function findByMonth(\DateTime $date)
    {
        $qb = $this->createQueryBuilder('i');

        $start = clone $date;
        $start->modify('first day of this month');
        $start->setTime(0,0,0);

        $end = clone $date;
        $end->modify('last day of this month');
        $end->setTime(23,59,59);

        return $qb
            ->join('i.order', 'o')
            ->andWhere($qb->expr()->between('i.createdAt', ':start', ':end'))
            ->andWhere($qb->expr()->eq('o.sample', ':sample'))
            ->addOrderBy('i.createdAt', 'ASC')
            ->getQuery()
            ->setParameter('start', $start, Type::DATETIME)
            ->setParameter('end', $end, Type::DATETIME)
            ->setParameter('sample', false)
            ->getResult();
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Transformer;

use Ekyna\Component\Commerce\Cart\Model\CartInterface;
use Ekyna\Component\Commerce\Common\Event\SaleTransformEvent;
use Ekyna\Component\Commerce\Common\Event\SaleTransformEvents;
use Ekyna\Component\Commerce\Common\Listener\UploadableListener;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Order\Model\OrderInterface;
use Ekyna\Component\Commerce\Quote\Model\QuoteInterface;
use Ekyna\Component\Resource\Operator\ResourceOperatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Class SaleTransformer
 * @package Ekyna\Component\Commerce\Common\Transformer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 *
 * @TODO    Use a "resource service factory" to get operators
 */
class SaleTransformer implements SaleTransformerInterface
{
    /**
     * @var SaleCopierFactoryInterface
     */
    private $saleCopierFactory;

    /**
     * @var ResourceOperatorInterface
     */
    private $cartOperator;

    /**
     * @var ResourceOperatorInterface
     */
    private $quoteOperator;

    /**
     * @var ResourceOperatorInterface
     */
    private $orderOperator;

    /**
     * @var UploadableListener
     */
    private $uploadableListener;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

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

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

    /**
     * Constructor.
     *
     * @param SaleCopierFactoryInterface $saleCopierFactory
     * @param ResourceOperatorInterface  $cartOperator
     * @param ResourceOperatorInterface  $quoteOperator
     * @param ResourceOperatorInterface  $orderOperator
     * @param UploadableListener         $uploadableListener
     * @param EventDispatcherInterface   $eventDispatcher
     */
    public function __construct(
        SaleCopierFactoryInterface $saleCopierFactory,
        ResourceOperatorInterface $cartOperator,
        ResourceOperatorInterface $quoteOperator,
        ResourceOperatorInterface $orderOperator,
        UploadableListener $uploadableListener,
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->saleCopierFactory = $saleCopierFactory;
        $this->cartOperator = $cartOperator;
        $this->quoteOperator = $quoteOperator;
        $this->orderOperator = $orderOperator;
        $this->uploadableListener = $uploadableListener;
        $this->eventDispatcher = $eventDispatcher;
    }

    /**
     * @inheritdoc
     */
    public function initialize(SaleInterface $source, SaleInterface $target)
    {
        $this->source = $source;
        $this->target = $target;

        $event = $this->getOperator($this->target)->initialize($this->target);
        if ($event->isPropagationStopped()) {
            return $event;
        }

        $event = new SaleTransformEvent($this->source, $this->target);

        $this->eventDispatcher->dispatch(SaleTransformEvents::PRE_COPY, $event);
        if ($event->isPropagationStopped()) {
            return $event;
        }

        $this
            ->saleCopierFactory
            ->create($this->source, $this->target)
            ->copySale();

        $this->eventDispatcher->dispatch(SaleTransformEvents::POST_COPY, $event);

        return $event;
    }

    /**
     * Transforms the given source sale to the given target sale.
     *
     * @return \Ekyna\Component\Resource\Event\ResourceEventInterface|null The event that stopped transformation if any.
     *
     * @throws LogicException If initialize has not been called first.
     */
    public function transform()
    {
        if (null === $this->source || null === $this->target) {
            throw new LogicException("Please call initialize first.");
        }

        $event = new SaleTransformEvent($this->source, $this->target);

        $this->eventDispatcher->dispatch(SaleTransformEvents::PRE_TRANSFORM, $event);
        if ($event->hasErrors() || $event->isPropagationStopped()) {
            return $event;
        }

        // Persist the target sale
        $targetEvent = $this->getOperator($this->target)->persist($this->target);
        if (!$targetEvent->isPropagationStopped() && !$targetEvent->hasErrors()) {
            // Disable the uploadable listener
            $this->uploadableListener->setEnabled(false);

            // Delete the source sale
            $sourceEvent = $this->getOperator($this->source)->delete($this->source, true); // Hard delete
            if (!$sourceEvent->isPropagationStopped() && !$sourceEvent->hasErrors()) {
                $targetEvent = null;
            }

            // Enable the uploadable listener
            $this->uploadableListener->setEnabled(true);
        }

        $this->eventDispatcher->dispatch(SaleTransformEvents::POST_TRANSFORM, $event);
        if ($event->hasErrors() || $event->isPropagationStopped()) {
            return $event;
        }

        // Unset source and target sales
        $this->source = null;
        $this->target = null;

        return $targetEvent;
    }

    /**
     * Returns the proper operator for the given sale.
     *
     * @param SaleInterface $sale
     *
     * @return ResourceOperatorInterface
     */
    protected function getOperator(SaleInterface $sale)
    {
        if ($sale instanceof CartInterface) {
            return $this->cartOperator;
        } elseif ($sale instanceof QuoteInterface) {
            return $this->quoteOperator;
        } elseif ($sale instanceof OrderInterface) {
            return $this->orderOperator;
        }

        throw new InvalidArgumentException("Unexpected sale type.");
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Helper;

use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Commerce\Common\Util\FormatterFactory;
use Ekyna\Component\Commerce\Stock\Model\Availability;
use Ekyna\Component\Commerce\Stock\Model\StockSubjectInterface;
use Ekyna\Component\Commerce\Stock\Model\StockSubjectModes;

/**
 * Class AvailabilityHelper
 * @package Ekyna\Component\Commerce\Stock\Helper
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractAvailabilityHelper implements AvailabilityHelperInterface
{
    use FormatterAwareTrait;

    /**
     * @var int
     */
    protected $inStockLimit;

    /**
     * Constructor.
     *
     * @param FormatterFactory $formatterFactory
     * @param int              $inStockLimit
     */
    public function __construct(FormatterFactory $formatterFactory, $inStockLimit = 100)
    {
        $this->formatterFactory = $formatterFactory;
        $this->inStockLimit = $inStockLimit;
    }

    /**
     * Returns the subject availability for the given quantity.
     *
     * @param StockSubjectInterface $subject
     * @param bool                  $root
     * @param bool                  $short
     *
     * @return Availability
     */
    public function getAvailability(StockSubjectInterface $subject, bool $root = true, bool $short = false)
    {
        $minQty = $aQty = $rQty = 0;
        $maxQty = INF;
        $minMsg = $maxMsg = $aMsg = $rMsg = null;

        $formatter = $this->getFormatter();

        if ($root && $subject->isQuoteOnly()) {
            $maxQty = 0;
            $oMsg = $maxMsg = $this->translate('quote_only', [], $short);
        } else {
            // Minimum quantity/message
            if (0 < $minQty = $subject->getMinimumOrderQuantity()) {
                $minMsg = $this->translate('min_quantity', [
                    '%min%' => $formatter->number($minQty),
                ], $short);
            }

            // Available quantity/message
            if ($subject->getStockMode() === StockSubjectModes::MODE_DISABLED) {
                $aQty = INF;
                $aMsg = $this->translate('available', [], $short);
            } elseif (0 < $aQty = $subject->getAvailableStock()) {
                $maxQty = $aQty;
                $aMsg = $this->translate('in_stock', [
                    '%qty%' => $formatter->number($aQty),
                ], $short);
            } else {
                $maxQty = 0;
            }

            // Resupply quantity/message
            if ((0 < $qty = $subject->getVirtualStock()) && (null !== $eda = $subject->getEstimatedDateOfArrival())) {
                $today = new \DateTime();
                $today->setTime(23, 59, 59);
                if ($today < $eda && 0 < $rQty = $qty - $aQty) {
                    $rMsg = $this->translate('pre_order', [
                        '%eda%' => $formatter->date($eda),
                        '%qty%' => $formatter->number($qty),
                    ], $short);
                    $maxQty = $qty;
                }
            }

            // Overflow message
            if ($root && $subject->isEndOfLife()) {
                $oMsg = $this->translate('end_of_life', [], $short);
            } elseif (
                $subject->getStockMode() === StockSubjectModes::MODE_JUST_IN_TIME &&
                0 < $days = $subject->getReplenishmentTime()
            ) {
                $maxQty = INF;
                $oMsg = $this->translate('replenishment', [
                    '%days%' => $days,
                ], $short);
            } else {
                $oMsg = $this->translate('out_of_stock', [], $short);
            }

            if (0 < $maxQty && $maxQty !== INF) {
                $maxMsg = $this->translate('max_quantity', [
                    '%max%' => $formatter->number($maxQty),
                ], $short);
            } elseif (0 == $maxQty) {
                $maxMsg = $oMsg;
            }
        }

        return new Availability($oMsg, $minQty, $minMsg, $maxQty, $maxMsg, $aQty, $aMsg, $rQty, $rMsg);
    }

    /**
     * @inheritdoc
     */
    public function getAvailabilityMessage(
        StockSubjectInterface $subject,
        float $quantity = null,
        bool $root = true,
        bool $short = false
    ) {
        $formatter = $this->formatterFactory->create();

        if (is_null($quantity)) {
            $quantity = $subject->getMinimumOrderQuantity();
        }

        if ($root && $subject->isQuoteOnly()) {
            return $this->translate('quote_only', [], $short);
        }

        if ($subject->getStockMode() === StockSubjectModes::MODE_DISABLED) {
            return $this->translate('available', [], $short);
        }

        if ($quantity < $qty = $subject->getAvailableStock()) {
            if (0 < $this->inStockLimit && $this->inStockLimit < $qty) {
                return $this->translate('in_stock', [
                    '%qty%' => $this->inStockLimit . '+',
                ], $short);
            }

            return $this->translate('in_stock', [
                '%qty%' => $formatter->number($qty),
            ], $short);
        }

        // TODO Only if stock mode === JUST_IN_TIME (?)
        if (($quantity < $qty = $subject->getVirtualStock()) && (null !== $eda = $subject->getEstimatedDateOfArrival())) {
            $today = new \DateTime();
            $today->setTime(23, 59, 59);
            if ($today < $eda) {
                return $this->translate('pre_order', [
                    '%eda%' => $formatter->date($eda),
                    '%qty%' => $formatter->number($qty),
                ], $short);
            }
        }

        if ($root && $subject->isEndOfLife()) {
            return $this->translate('end_of_life', [], $short);
        }

        if (
            $subject->getStockMode() === StockSubjectModes::MODE_JUST_IN_TIME &&
            0 < $days = $subject->getReplenishmentTime()
        ) {
            return $this->translate('replenishment', [
                '%days%' => $days,
            ], $short);
        }

        return $this->translate('out_of_stock', [], $short);
    }
}


<?php

namespace mii\console;

use mii\core\ErrorException;
use mii\util\Console;

class ErrorHandler extends \mii\core\ErrorHandler
{

    public $memory_reserve_size = 262144;

    public function render($exception) {

        if ($exception instanceof CliException && !config('debug')) {
            $message = $this->formatMessage($exception->get_name() . ': ') . $exception->getMessage();
        } elseif (config('debug')) {
            if ($exception instanceof CliException) {

                $message = $this->formatMessage("Exception ({$exception->get_name()})");

            } elseif ($exception instanceof ErrorException) {
                $message = $this->formatMessage($exception->get_name());
            } else {
                $message = $this->formatMessage('Exception');
            }

            $message .= $this->formatMessage(" '" . get_class($exception) . "'", [Console::BOLD, Console::FG_BLUE])
                . ' with message ' . $this->formatMessage("'{$exception->getMessage()}'", [Console::BOLD]) //. "\n"
                . "\n\nin " . dirname($exception->getFile()) . DIRECTORY_SEPARATOR . $this->formatMessage(basename($exception->getFile()), [Console::BOLD])
                . ':' . $this->formatMessage($exception->getLine(), [Console::BOLD, Console::FG_YELLOW]) . "\n"
                . "\n" . $this->formatMessage("Stack trace:\n", [Console::BOLD]) . $exception->getTraceAsString();
        } else {
            $message = $this->formatMessage('Error: ') . $exception->getMessage();
        }

        if (PHP_SAPI === 'cli') {
            Console::stderr($message . "\n");
        } else {
            echo $message . "\n";
        }
    }

    /**
     * Colorizes a message for console output.
     * @param string $message the message to colorize.
     * @param array $format the message format.
     * @return string the colorized message.
     * @see Console::ansiFormat() for details on how to specify the message format.
     */
    protected function formatMessage($message, $format = [Console::FG_RED, Console::BOLD]) {
        $stream = (PHP_SAPI === 'cli') ? \STDERR : \STDOUT;

        if (Console::stream_supports_ansi_colors($stream)) {
            $message = Console::ansi_format($message, $format);
        }

        return $message;
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Cache;

use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Stock\Model\StockSubjectInterface;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;
use Ekyna\Component\Commerce\Stock\Model\StockUnitStates;
use Ekyna\Component\Resource\Event\QueueEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Class StockUnitCache
 * @package Ekyna\Component\Commerce\Stock\Cache
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class StockUnitCache implements StockUnitCacheInterface, EventSubscriberInterface
{
    /**
     * @var StockUnitInterface[][]
     * [identity => StockUnitInterface]
     */
    protected $addedUnits;

    /**
     * @var StockUnitInterface[][]
     * [identity => StockUnitInterface]
     */
    protected $removedUnits;

    /**
     * Constructor.
     */
    public function __construct()
    {
        $this->clear();
    }

    /**
     * Event queue close handler.
     */
    public function onEventQueueClose()
    {
        $this->clear();
    }

    /**
     * @inheritdoc
     */
    public function add(StockUnitInterface $unit)
    {
        if (null === $subject = $unit->getSubject()) {
            throw new LogicException("Stock unit's subject must be set.");
        }

        $oid = spl_object_hash($subject);

        // Clears the unit from the removed list
        $this->pop($this->removedUnits, $oid, $unit);

        // Adds the unit the added list
        $this->push($this->addedUnits, $oid, $unit);
    }

    /**
     * @inheritdoc
     */
    public function isAdded(StockUnitInterface $unit)
    {
        if (null === $subject = $unit->getSubject()) {
            throw new LogicException("Stock unit's subject must be set.");
        }

        $oid = spl_object_hash($subject);

        return $this->has($this->addedUnits, $oid, $unit);
    }

    /**
     * @inheritdoc
     */
    public function remove(StockUnitInterface $unit)
    {
        if (null === $subject = $unit->getSubject()) {
            throw new LogicException("Stock unit's subject must be set.");
        }

        $oid = spl_object_hash($subject);

        // Clears the unit from the added list
        $this->pop($this->addedUnits, $oid, $unit);

        // Adds the unit the removed list
        $this->push($this->removedUnits, $oid, $unit);
    }

    /**
     * @inheritdoc
     */
    public function isRemoved(StockUnitInterface $unit)
    {
        if (null === $subject = $unit->getSubject()) {
            throw new LogicException("Stock unit's subject must be set.");
        }

        $oid = spl_object_hash($subject);

        return $this->has($this->removedUnits, $oid, $unit);
    }

    /**
     * @inheritdoc
     */
    public function findNewBySubject(StockSubjectInterface $subject)
    {
        return $this->findBySubjectAndStates($subject, [
            StockUnitStates::STATE_NEW,
        ]);
    }

    /**
     * @inheritdoc
     */
    public function findPendingBySubject(StockSubjectInterface $subject)
    {
        return $this->findBySubjectAndStates($subject, [
            StockUnitStates::STATE_PENDING,
        ]);
    }

    /**
     * @inheritdoc
     */
    public function findReadyBySubject(StockSubjectInterface $subject)
    {
        return $this->findBySubjectAndStates($subject, [
            StockUnitStates::STATE_READY,
        ]);
    }

    /**
     * @inheritdoc
     */
    public function findPendingOrReadyBySubject(StockSubjectInterface $subject)
    {
        return $this->findBySubjectAndStates($subject, [
            StockUnitStates::STATE_PENDING,
            StockUnitStates::STATE_READY,
        ]);
    }

    /**
     * @inheritdoc
     */
    public function findNotClosedBySubject(StockSubjectInterface $subject)
    {
        return $this->findBySubjectAndStates($subject, [
            StockUnitStates::STATE_NEW,
            StockUnitStates::STATE_PENDING,
            StockUnitStates::STATE_READY,
        ]);
    }

    /**
     * @inheritdoc
     */
    public function findAssignableBySubject(StockSubjectInterface $subject)
    {
        $units = [];

        // Find by subject oid
        $oid = spl_object_hash($subject);
        if (isset($this->addedUnits[$oid])) {
            $units = $this->addedUnits[$oid];
        }

        // Filter by :
        // - Not yet linked to a supplier order
        // - Sold lower than ordered
        if (!empty($units)) {
            $units = array_filter($units, function (StockUnitInterface $unit) {
                return null === $unit->getSupplierOrderItem()
                    || ($unit->getSoldQuantity() < $unit->getOrderedQuantity() + $unit->getAdjustedQuantity());
            });
        }

        return $units;
    }

    /**
     * @inheritdoc
     */
    public function findLinkableBySubject(StockSubjectInterface $subject)
    {
        $units = [];

        // Find by subject oid
        $oid = spl_object_hash($subject);
        if (isset($this->addedUnits[$oid])) {
            $units = $this->addedUnits[$oid];
        }

        // Filter by :
        // - Not closed
        // - Not yet linked to a supplier order
        if (!empty($units)) {
            $units = array_filter($units, function (StockUnitInterface $unit) {
                return (null === $unit->getSupplierOrderItem())
                    && ($unit->getState() !== StockUnitStates::STATE_CLOSED);
            });
        }

        return $units;
    }

    /**
     * Finds the suck units by subject and states.
     *
     * @param StockSubjectInterface $subject
     * @param array                 $states
     *
     * @return array|StockUnitInterface[]
     */
    private function findBySubjectAndStates(StockSubjectInterface $subject, array $states = [])
    {
        $units = [];

        // Find by subject oid
        $oid = spl_object_hash($subject);
        if (isset($this->addedUnits[$oid])) {
            $units = $this->addedUnits[$oid];
        }

        // Filter by states
        if (!empty($units) && !empty($states)) {
            $units = array_filter($units, function (StockUnitInterface $unit) use ($states) {
                return in_array($unit->getState(), $states);
            });
        }

        return $units;
    }

    /**
     * Returns whether the unit exists into the given list for the given object identifier.
     *
     * @param array              $list
     * @param string             $oid
     * @param StockUnitInterface $unit
     *
     * @return bool
     */
    private function has(array &$list, $oid, StockUnitInterface $unit)
    {
        if (!isset($list[$oid])) {
            return false;
        }

        return false !== $this->find($list, $oid, $unit);
    }

    /**
     * Finds the unit into the given list for the given object identifier.
     *
     * @param array              $list
     * @param string             $oid
     * @param StockUnitInterface $unit
     *
     * @return bool|false|int|string
     */
    private function find(array &$list, $oid, StockUnitInterface $unit)
    {
        if (!isset($list[$oid])) {
            return false;
        }

        // Non persisted search
        if (null === $unit->getId()) {
            return array_search($unit, $list[$oid], true);
        }

        // Persisted search
        /** @var StockUnitInterface $u */
        foreach ($list[$oid] as $index => $u) {
            if ($u->getId() == $unit->getId()) {
                return $index;
            }
        }

        return false;
    }

    /**
     * Pushes the unit to the given list for the given object identifier.
     *
     * @param array              $list
     * @param string             $oid
     * @param StockUnitInterface $unit
     */
    private function push(array &$list, $oid, StockUnitInterface $unit)
    {
        if (!$this->has($list, $oid, $unit)) {
            $list[$oid][] = $unit;
        }
    }

    /**
     * Pops the unit to the given list for the given object identifier.
     *
     * @param array              $list
     * @param string             $oid
     * @param StockUnitInterface $unit
     */
    private function pop(array &$list, $oid, StockUnitInterface $unit)
    {
        if (false !== $index = $this->find($list, $oid, $unit)) {
            unset($list[$oid][$index]);

            if (empty($list[$oid])) {
                unset($list[$oid]);
            }
        }
    }

    /**
     * Clears the stock unit lists.
     */
    private function clear()
    {
        $this->addedUnits = [];
        $this->removedUnits = [];
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents()
    {
        return [
            QueueEvents::QUEUE_CLOSE => ['onEventQueueClose', 0],
        ];
    }
}


<?php

namespace PeskyCMF\Db\Traits;

use PeskyCMF\Db\CmfDbRecord;

trait CacheableRecord {

    protected $_cacheOnceTimeout = false;

    /**
     * Set cache timeout for next read() or find() method call
     * @param null|int|\DateTime $timeout - int: minutes
     * @return $this
     */
    public function withCacheTimeout($timeout = null) {
        /** @var CmfDbRecord|CacheableRecord $this */
        $this->_cacheOnceTimeout = empty($timeout)
            ? $this->getTable()->getDefaultCacheDurationForSelectOneInMinutes()
            : $timeout;
        return $this;
    }

    /**
     * @param array $conditionsAndOptions
     * @param array $columns
     * @param array $readRelatedRecords
     * @return $this
     */
    public function fromDb(array $conditionsAndOptions, array $columns = [], array $readRelatedRecords = []) {
        if ($this->_cacheOnceTimeout !== false) {
            $conditionsAndOptions['CACHE'] = ['timeout' => $this->_cacheOnceTimeout];
            $this->_cacheOnceTimeout = false;
        }
        return parent::fromDb($conditionsAndOptions, $columns, $readRelatedRecords);
    }

    /**
     * @param array $columns
     * @param array $readRelatedRecords
     * @return $this
     */
    public function reload(array $columns = [], array $readRelatedRecords = []) {
        $this->_cacheOnceTimeout = false;
        return parent::reload($columns, $readRelatedRecords);
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Calculator;

use Ekyna\Component\Commerce\Common\Currency\CurrencyConverterInterface;
use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Commerce\Shipment\Calculator\WeightCalculatorInterface;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentSubjectInterface;
use Ekyna\Component\Commerce\Shipment\Resolver\ShipmentAddressResolverInterface;
use Ekyna\Component\Commerce\Shipment\Resolver\ShipmentPriceResolverInterface;
use Ekyna\Component\Commerce\Stock\Model\StockAssignmentsInterface;
use Ekyna\Component\Commerce\Subject\Guesser\PurchaseCostGuesserInterface;
use Ekyna\Component\Commerce\Subject\SubjectHelperInterface;

/**
 * Class MarginCalculator
 * @package Ekyna\Component\Commerce\Common\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class MarginCalculator implements MarginCalculatorInterface
{
    /**
     * @var AmountCalculatorInterface
     */
    private $amountCalculator;

    /**
     * @var WeightCalculatorInterface
     */
    private $weightCalculator;

    /**
     * @var ShipmentPriceResolverInterface
     */
    private $shipmentPriceResolver;

    /**
     * @var ShipmentAddressResolverInterface
     */
    private $shipmentAddressResolver;

    /**
     * @var SubjectHelperInterface
     */
    private $subjectHelper;

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

    /**
     * @var PurchaseCostGuesserInterface
     */
    private $purchaseCostGuesser;

    /**
     * Constructor.
     *
     * @param AmountCalculatorInterface        $amountCalculator
     * @param WeightCalculatorInterface        $weightCalculator
     * @param ShipmentPriceResolverInterface   $shipmentPriceResolver
     * @param ShipmentAddressResolverInterface $shipmentAddressResolver
     * @param SubjectHelperInterface           $subjectHelper
     * @param CurrencyConverterInterface       $currencyConverter
     * @param PurchaseCostGuesserInterface     $purchaseCostGuesser
     */
    public function __construct(
        AmountCalculatorInterface $amountCalculator,
        WeightCalculatorInterface $weightCalculator,
        ShipmentPriceResolverInterface $shipmentPriceResolver,
        ShipmentAddressResolverInterface $shipmentAddressResolver,
        SubjectHelperInterface $subjectHelper,
        CurrencyConverterInterface $currencyConverter,
        PurchaseCostGuesserInterface $purchaseCostGuesser
    ) {
        $this->amountCalculator = $amountCalculator;
        $this->weightCalculator = $weightCalculator;
        $this->shipmentPriceResolver = $shipmentPriceResolver;
        $this->shipmentAddressResolver = $shipmentAddressResolver;
        $this->subjectHelper = $subjectHelper;
        $this->currencyConverter = $currencyConverter;
        $this->purchaseCostGuesser = $purchaseCostGuesser;
    }

    /**
     * @inheritdoc
     */
    public function calculateSale(Model\SaleInterface $sale): ?Margin
    {
        if (null !== $margin = $sale->getMargin()) {
            return $margin;
        }

        if (!$sale->hasItems() || $sale->isSample()) {
            return null;
        }

        $this->amountCalculator->calculateSale($sale);

        $margin = new Margin();

        $cancel = true;
        foreach ($sale->getItems() as $item) {
            if (null !== $itemMargin = $this->calculateSaleItem($item)) {
                $margin->merge($itemMargin);
                $cancel = false;
            }
        }

        if ($cancel) {
            return null;
        }

        foreach ($sale->getAdjustments(Model\AdjustmentTypes::TYPE_DISCOUNT) as $adjustment) {
            $margin->addSellingPrice(-$adjustment->getResult()->getBase());
        }

        $margin->merge($this->calculateSaleShipment($sale));

        $sale->setMargin($margin);

        return $margin;
    }

    /**
     * @inheritdoc
     */
    public function calculateSaleItem(Model\SaleItemInterface $item): ?Margin
    {
        if (null !== $margin = $item->getMargin()) {
            return $margin;
        }

        $margin = new Margin();

        $result = $this->amountCalculator->calculateSaleItem($item);
        $currency = $item->getSale()->getCurrency()->getCode();

        $margin->addSellingPrice($result->getBase());

        if (!$item->isCompound()) {
            if ($item instanceof StockAssignmentsInterface && $item->hasStockAssignments()) {
                foreach ($item->getStockAssignments() as $assignment) {
                    if (0 < $netPrice = $assignment->getStockUnit()->getNetPrice()) {
                        $c = $assignment->getStockUnit()->getCurrency();
                        $cost = $this->currencyConverter->convert($netPrice, $c, $currency);
                        $margin->addPurchaseCost($assignment->getSoldQuantity() * $cost);
                    } elseif (null !== $cost = $this->getPurchaseCost($item)) {
                        $margin
                            ->addPurchaseCost($cost * $assignment->getSoldQuantity())
                            ->setAverage(true);
                    } else {
                        $margin->setAverage(true);
                    }
                }
            } elseif (null !== $cost = $this->getPurchaseCost($item)) {
                $margin
                    ->addPurchaseCost($cost * $item->getTotalQuantity())
                    ->setAverage(true);
            } else {
                return null;
            }
        }

        foreach ($item->getChildren() as $child) {
            if (null !== $childMargin = $this->calculateSaleItem($child)) {
                if ($child->isPrivate()) {
                    if (0 < $cost = $childMargin->getPurchaseCost()) {
                        $margin->addPurchaseCost($childMargin->getPurchaseCost());
                    } else {
                        $margin->setAverage(true);
                    }
                } else {
                    $margin->merge($childMargin);
                }
            } else {
                $margin->setAverage(true);
            }
        }

        $item->setMargin($margin);

        return $margin;
    }

    /**
     * Returns the sale item purchase cost.
     *
     * @param Model\SaleItemInterface $item
     *
     * @return float|null
     */
    private function getPurchaseCost(Model\SaleItemInterface $item)
    {
        /** @var \Ekyna\Component\Commerce\Subject\Model\SubjectInterface $subject */
        if (null === $subject = $this->subjectHelper->resolve($item)) {
            return null;
        }

        $currency = $item->getSale()->getCurrency()->getCode();

        if (null !== $cost = $this->purchaseCostGuesser->guess($subject, $currency)) {
            return $cost;
        }

        return null;
    }

    /**
     * @inheritdoc
     */
    public function calculateSaleShipment(Model\SaleInterface $sale): Margin
    {
        $baseC = $this->currencyConverter->getDefaultCurrency();
        $quoteC = $sale->getCurrency()->getCode();

        if (($baseC != $quoteC) && (!is_null($rate = $sale->getExchangeRate()))) {
            $sellPrice = $this->currencyConverter->convertWithRate($sale->getShipmentAmount(), $rate, $quoteC);
        } else {
            $sellPrice = $this->currencyConverter->convert($sale->getShipmentAmount(), $baseC, $quoteC);
        }

        $margin = new Margin(0, $sellPrice);

        if ($sale instanceof ShipmentSubjectInterface && 0 < $sale->getShipments()->count()) {
            foreach ($sale->getShipments() as $shipment) {
                $deliveryAddress = $this->shipmentAddressResolver->resolveReceiverAddress($shipment, true);
                $country = $deliveryAddress->getCountry();
                $method = $shipment->getMethod();

                if ($country && $method) {
                    $weight = $this->weightCalculator->calculateShipment($shipment);
                    $price = $this
                        ->shipmentPriceResolver
                        ->getPriceByCountryAndMethodAndWeight($country, $method, $weight);

                    if (null !== $price) {
                        $margin->addPurchaseCost(
                            $this->currencyConverter->convert(
                                $price->getPrice(), $baseC, $quoteC, $shipment->getShippedAt()
                            )
                        );
                        continue;
                    }
                }

                $margin->setAverage(true);
            }

            return $margin;
        }

        $country = $sale->getDeliveryCountry();
        $method = $sale->getShipmentMethod();
        $weight = $sale->getWeightTotal();

        if ($country && $method) {
            $price = $this
                ->shipmentPriceResolver
                ->getPriceByCountryAndMethodAndWeight($country, $method, $weight);

            if (null !== $price) {
                $margin->addPurchaseCost(
                    $this->currencyConverter->convert($price->getPrice(), $baseC, $quoteC)
                );
            } else {
                $margin->setAverage(true);
            }
        } else {
            $margin->setAverage(true);
        }

        return $margin;
    }
}


<?php

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

use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Commerce\Support\Model\TicketMessageInterface;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

/**
 * Class TicketMessageNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class TicketMessageNormalizer extends AbstractResourceNormalizer
{
    use FormatterAwareTrait;

    /**
     * @inheritDoc
     *
     * @param TicketMessageInterface $message
     */
    public function normalize($message, $format = null, array $context = [])
    {
        if ($this->contextHasGroup(['Default', 'Ticket'], $context)) {
            $formatter = $this->getFormatter();

            $data = [
                'id'            => $message->getId(),
                'ticket'        => $message->getTicket()->getId(),
                'content'       => $message->getContent(),
                'author'        => $message->getAuthor(),
                'notified_at'   => ($date = $message->getNotifiedAt()) ? $date->format('Y-m-d H:i:s') : null,
                'f_notified_at' => ($date = $message->getNotifiedAt()) ? $formatter->dateTime($date) : null,
                'created_at'    => ($date = $message->getCreatedAt()) ? $date->format('Y-m-d H:i:s') : null,
                'f_created_at'  => ($date = $message->getCreatedAt()) ? $formatter->dateTime($date) : null,
                'updated_at'    => ($date = $message->getUpdatedAt()) ? $date->format('Y-m-d H:i:s') : null,
                'f_updated_at'  => ($date = $message->getUpdatedAt()) ? $formatter->dateTime($date) : null,
                'attachments'   => [],
            ];

            foreach ($this->filterAttachments($message) as $attachment) {
                $data['attachments'][] = $this->normalizeObject($attachment, $format, $context);
            }

            return $data;
        }

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

    /**
     * Filters the attachments.
     *
     * @param TicketMessageInterface $message
     *
     * @return \Ekyna\Component\Commerce\Support\Model\TicketAttachmentInterface[]
     */
    protected function filterAttachments(TicketMessageInterface $message)
    {
        return $message->getAttachments()->toArray();
    }

    /**
     * @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 TicketMessageInterface;
    }

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


<?php

namespace Ekyna\Component\Commerce\Stock\EventListener;

use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Stock\Event\SubjectStockUnitEvent;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;
use Ekyna\Component\Commerce\Stock\Resolver\StockUnitStateResolverInterface;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderStates;
use Ekyna\Component\Resource\Dispatcher\ResourceEventDispatcherInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

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

    /**
     * @var ResourceEventDispatcherInterface
     */
    protected $dispatcher;

    /**
     * @var StockUnitStateResolverInterface
     */
    protected $stateResolver;

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

    /**
     * Sets the resource event dispatcher.
     *
     * @param ResourceEventDispatcherInterface $dispatcher
     */
    public function setDispatcher(ResourceEventDispatcherInterface $dispatcher)
    {
        $this->dispatcher = $dispatcher;
    }

    /**
     * Sets the stock unit state resolver.
     *
     * @param StockUnitStateResolverInterface $stateResolver
     */
    public function setStateResolver(StockUnitStateResolverInterface $stateResolver)
    {
        $this->stateResolver = $stateResolver;
    }

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

        if ($this->stateResolver->resolve($stockUnit)) {
            $this->persistenceHelper->persistAndRecompute($stockUnit, false);
        }

        $this->scheduleSubjectStockUnitChangeEvent($stockUnit);
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $stockUnit = $this->getStockUnitFromEvent($event);

        $trackProperties = [
            'state',
            'orderedQuantity',
            'receivedQuantity',
            'adjustedQuantity',
            'soldQuantity',
            'shippedQuantity',
            'estimatedDateOfArrival'
        ];

        if ($this->persistenceHelper->isChanged($stockUnit, $trackProperties)) {
            if ($this->stateResolver->resolve($stockUnit)) {
                $this->persistenceHelper->persistAndRecompute($stockUnit, false);
            }

            $this->scheduleSubjectStockUnitChangeEvent($stockUnit);
        }
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $stockUnit = $this->getStockUnitFromEvent($event);

        if (null !== $item = $stockUnit->getSupplierOrderItem()) {
            // Prevent deletion if the supplier order has a stockable state
            if (SupplierOrderStates::isStockableState($item->getOrder()->getState())) {
                throw new IllegalOperationException(
                    "The stock unit can't be deleted as it is linked to a supplier order with a stockable state."
                ); // TODO message as translation id
            }
        }

        if (!$stockUnit->isEmpty()) {
            throw new IllegalOperationException(
                "The stock unit can't be deleted as it has been received, adjusted, sold or shipped."
            ); // TODO message as translation id
        }

        $this->scheduleSubjectStockUnitChangeEvent($stockUnit);
    }

    /**
     * Dispatches the subject's "stock unit change" event.
     *
     * @param StockUnitInterface $stockUnit
     */
    protected function scheduleSubjectStockUnitChangeEvent(StockUnitInterface $stockUnit)
    {
        $this->persistenceHelper->scheduleEvent(
            $this->getSubjectStockUnitChangeEventName(),
            new SubjectStockUnitEvent($stockUnit)
        );
    }

    /**
     * Returns the stock unit from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return StockUnitInterface
     * @throws InvalidArgumentException
     */
    abstract protected function getStockUnitFromEvent(ResourceEventInterface $event);

    /**
     * Returns the subject's "stock unit change" event name.
     *
     * @return string
     */
    abstract protected function getSubjectStockUnitChangeEventName();
}


<?php

namespace SnooPHP\Http;

/**
 * Handles redirection
 *
 * @author sneppy
 */
class Redirect extends Response
{
	/**
	 * Create a new redirect response
	 *
	 * @param string	$url	redirection URL
	 * @param int		$code	HTTP redirect code (>= 300)
	 */
	public function __construct($url, $code = 302)
	{
		// Check code for redirect
		// But allow anyway
		// Just throw a warning
		if ($code < 300 || $code >= 400) error_log("warning: invalid http status code ($code) for redirect");

		// Parent constructor with 'Location' header
		parent::__construct("", $code, [
			"Location" => $url
		]);
	}

	/**
	 * Parse response and redirect
	 */
	public function parse()
	{
		// Set response code
		http_response_code($this->code);

		// Set response header
		foreach ($this->headers as $header => $val)
		{
			header("$header: $val");
		}
	}

	/**
	 * Immediately redirect
	 *
	 * @param string	$url	redirection URL
	 * @param int		$code	HTTP redirect code (>= 300)
	 */
	public static function now($url, $code = 302)
	{
		$redirect = new Redirect($url, $code);
		$redirect->parse();
		exit;
	}
}


<?php

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

use Ekyna\Component\Commerce\Shipment\Model\RelayPointInterface;
use Ekyna\Component\Resource\Locale\LocaleProviderInterface;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

/**
 * Class RelayPointNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class RelayPointNormalizer extends AbstractResourceNormalizer
{
    /**
     * @var LocaleProviderInterface
     */
    private $localeProvider;

    /**
     * Constructor.
     *
     * @param LocaleProviderInterface $localeProvider
     */
    public function __construct(LocaleProviderInterface $localeProvider)
    {
        $this->localeProvider = $localeProvider;
    }

    /**
     * @inheritDoc
     *
     * @param RelayPointInterface $relayPoint
     */
    public function normalize($relayPoint, $format = null, array $context = [])
    {
        if ($this->contextHasGroup(['Default', 'RelayPoint'], $context)) {
            $data = [
                'id'          => $relayPoint->getId(),
                'number'      => $relayPoint->getNumber(),
                'platform'    => $relayPoint->getPlatformName(),
                'company'     => $relayPoint->getCompany(),
                //'gender'      => $relayPoint->getGender(),
                //'first_name'  => $relayPoint->getFirstName(),
                //'last_name'   => $relayPoint->getLastName(),
                'street'      => $relayPoint->getStreet(),
                'complement'  => $relayPoint->getComplement(),
                'supplement'  => $relayPoint->getSupplement(),
                'postal_code' => $relayPoint->getPostalCode(),
                'city'        => $relayPoint->getCity(),
                'country'     => $relayPoint->getCountry()->getName(),
                //'state'        => $address->getCity(),

                'phone'  => $this->normalizeObject($relayPoint->getPhone(), $format, $context),
                'mobile' => $this->normalizeObject($relayPoint->getMobile(), $format, $context),

                'distance'  => $relayPoint->getDistance(),
                'longitude' => $relayPoint->getLongitude(),
                'latitude'  => $relayPoint->getLatitude(),
            ];

            foreach ($relayPoint->getOpeningHours() as $oh) {
                $data['opening_hours'][] = [
                    'day'    => $oh->getDay(),
                    'label'  => $this->localizedDayOfWeek($oh->getDay()),
                    'ranges' => $oh->getRanges(),
                ];
            }

            return $data;
        }

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

    /**
     * Returns the localized day of week.
     *
     * @param int $dayOfWeek
     *
     * @return string
     */
    protected function localizedDayOfWeek($dayOfWeek)
    {
        if (class_exists('\IntlDateFormatter')) {
            $date = new \DateTime('2017-01-01'); // Starts sunday
            $date->modify('+' . $dayOfWeek . ' days');

            $formatter = \IntlDateFormatter::create(
                $this->localeProvider->getCurrentLocale(),
                \IntlDateFormatter::NONE,
                \IntlDateFormatter::NONE,
                $date->getTimezone(),
                null,
                'eeee'
            );

            return $formatter->format($date->getTimestamp());
        }

        return [
                   1 => 'Monday',
                   2 => 'Tuesday',
                   3 => 'Wednesday',
                   4 => 'Thursday',
                   5 => 'Friday',
                   6 => 'Saturday',
                   7 => 'Sunday',
               ][$dayOfWeek];
    }

    /**
     * @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 RelayPointInterface;
    }

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


<?php

namespace PeskyCMF\Providers;

use Illuminate\Foundation\AliasLoader;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use PeskyCMF\Config\CmfConfig;
use PeskyCMF\Console\Commands\CmfAddAdminCommand;
use PeskyCMF\Console\Commands\CmfAddSectionCommand;
use PeskyCMF\Console\Commands\CmfInstallCommand;
use PeskyCMF\Console\Commands\CmfInstallHttpRequestsLoggingCommand;
use PeskyCMF\Console\Commands\CmfInstallHttpRequestsProfilingCommand;
use PeskyCMF\Console\Commands\CmfMakeApiDocCommand;
use PeskyCMF\Console\Commands\CmfMakeApiMethodDocCommand;
use PeskyCMF\Console\Commands\CmfMakeScaffoldCommand;
use PeskyCMF\Facades\PeskyCmf;
use PeskyCMF\PeskyCmfAppSettings;
use PeskyCMF\PeskyCmfManager;
use PeskyORM\ORM\TableInterface;
use PeskyORM\ORM\TableStructureInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Vluzrmos\LanguageDetector\Facades\LanguageDetector;
use Vluzrmos\LanguageDetector\Providers\LanguageDetectorServiceProvider;

/**
 * @property Application $app
 */
class PeskyCmfServiceProvider extends ServiceProvider {

    public function register() {
        $this->mergeConfigFrom($this->getConfigFilePath(), 'peskycmf');

        $this->setDefaultCmfConfig();

        $this->app->singleton(PeskyCmfManager::class, function ($app) {
            return new PeskyCmfManager($app);
        });

        $this->app->register(PeskyCmfPeskyOrmServiceProvider::class);
        $this->app->register(RecaptchaServiceProvider::class);
        AliasLoader::getInstance()->alias('LanguageDetector', LanguageDetector::class);
        AliasLoader::getInstance()->alias('PeskyCmf', PeskyCmf::class);

        if ($this->runningInConsole()) {
            $this->app->register(PeskyCmfLanguageDetectorServiceProvider::class);
            $this->app->alias(LanguageDetectorServiceProvider::class, PeskyCmfLanguageDetectorServiceProvider::class);
        }

        if ($this->app->environment() === 'local') {
            if (class_exists('\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider')) {
                $this->app->register('\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider');
            }
            if (class_exists('\Barryvdh\Debugbar\ServiceProvider')) {
                $this->app->register('\Barryvdh\Debugbar\ServiceProvider');
            }
        }

        $this->registerCommands();

        $this->app->singleton(PeskyCmfAppSettings::class, function () {
            /** @var PeskyCmfAppSettings $appSettingsClass */
            $appSettingsClass = $this->getAppConfigs()->get('peskycmf.app_settings_class') ?: PeskyCmfAppSettings::class;
            return $appSettingsClass::getInstance();
        });
    }

    public function provides() {
        return [
            CmfConfig::class,
            PeskyCmfAppSettings::class,
        ];
    }

    /**
     * @throws \UnexpectedValueException
     */
    public function boot() {
        require_once __DIR__ . '/../Config/helpers.php';

        $this->configurePublishes();

        $this->configureTranslations();
        $this->configureViews();

        $this->declareRoutesAndConfigsForAllCmfSections();
    }

    /**
     * Overwrite this method in your custom service provider if you do want or do not want to run
     * console-specific methods in register() and boot() methods
     * @return bool
     */
    protected function runningInConsole() {
        return $this->app->runningInConsole();
    }

    /**
     * @return ParameterBag
     */
    protected function getAppConfigs() {
        return $this->app['config'];
    }

    protected function configurePublishes() {
        if (!$this->runningInConsole()) {
            return ;
        }
        $cmfLibDir = __DIR__ . '/..';
        // publish cmf assets (minified)
        $cmfNpmDistDir = $cmfLibDir . '/../../npm/dist';
        $cmfAssets = [
            $cmfNpmDistDir . '/min' => public_path('packages/cmf/min'),
            $cmfNpmDistDir . '/packed' => public_path('packages/cmf/packed'),
            $cmfNpmDistDir . '/raw' => public_path('packages/cmf/raw'),
        ];
        if (stripos(config('peskycmf.assets'), 'src') === 0) {
            $cmfAssets[$cmfNpmDistDir . '/src'] = public_path('packages/cmf/src');
        }
        $this->publishes($cmfAssets, 'public');

        $this->publishes([
            $this->getConfigFilePath() => config_path('peskycmf.php'),
            $this->getOrmConfigFilePath() => config_path('peskyorm.php'),
            $cmfLibDir . '/Config/ru_validation_translations.php' => resource_path('lang/ru/validation.php'),
        ], 'config');
    }

    protected function getConfigFilePath() {
        return __DIR__ . '/../Config/peskycmf.config.php';
    }

    protected function getOrmConfigFilePath() {
        return __DIR__ . '/../Config/peskyorm.config.php';
    }

    protected function registerCommands() {
        $this->registerInstallCommand();
        $this->registerAddSectionCommand();
        $this->registerInstallHttpRequestsLoggingCommand();
        $this->registerInstallHttpRequestsProfilingCommand();
        $this->registerAddAdminCommand();
        $this->registerMakeScaffoldCommand();
        $this->registerMakeApiDocsCommands();
    }

    protected function registerInstallCommand() {
        $this->app->singleton('command.cmf.install', function() {
            return new CmfInstallCommand();
        });
        $this->commands('command.cmf.install');
    }

    protected function registerAddSectionCommand() {
        $this->app->singleton('command.cmf.add-section', function() {
            return new CmfAddSectionCommand();
        });
        $this->commands('command.cmf.add-section');
    }

    protected function registerAddAdminCommand() {
        $this->app->singleton('command.cmf.add-admin', function() {
            return new CmfAddAdminCommand();
        });
        $this->commands('command.cmf.add-admin');
    }

    protected function registerMakeScaffoldCommand() {
        $this->app->singleton('command.cmf.make-scaffold', function() {
            return new CmfMakeScaffoldCommand();
        });
        $this->commands('command.cmf.make-scaffold');
    }

    protected function registerInstallHttpRequestsLoggingCommand() {
        $this->app->singleton('command.cmf.install-http-requests-logging', function() {
            return new CmfInstallHttpRequestsLoggingCommand();
        });
        $this->commands('command.cmf.install-http-requests-logging');
    }

    protected function registerInstallHttpRequestsProfilingCommand() {
        $this->app->singleton('command.cmf.install-http-requests-profiling', function() {
            return new CmfInstallHttpRequestsProfilingCommand();
        });
        $this->commands('command.cmf.install-http-requests-profiling');
    }

    protected function registerMakeApiDocsCommands() {
        $this->app->singleton('command.cmf.make-api-docs', function() {
            return new CmfMakeApiDocCommand();
        });
        $this->commands('command.cmf.make-api-docs');

        $this->app->singleton('command.cmf.make-api-method-docs', function() {
            return new CmfMakeApiMethodDocCommand();
        });
        $this->commands('command.cmf.make-api-method-docs');
    }

    protected function configureTranslations() {
//        if (!\Lang::has('cmf::test', 'en')) {
            $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'cmf');
//        }
    }

    protected function configureViews() {
        $this->loadViewsFrom(__DIR__ . '/../resources/views', 'cmf');
    }

    /**
     * @return CmfConfig[]
     */
    protected function getAvailableCmfConfigs() {
        $cmfConfigsClasses = $this->getAppConfigs()->get('peskycmf.cmf_configs');
        $configs = [];
        /** @var CmfConfig $className */
        foreach ($cmfConfigsClasses as $sectionName => $className) {
            if (class_exists($className) && is_subclass_of($className, CmfConfig::class)) {
                $configs[$sectionName] = $className::getInstance();
            }
        }
        return $configs;
    }

    protected function declareRoutesAndConfigsForAllCmfSections() {
        foreach ($this->getAvailableCmfConfigs() as $sectionName => $cmfConfig) {
            if (!$this->app->routesAreCached()) {
                $cmfConfig::getInstance()->declareRoutes($sectionName);
            }
            if (!$this->app->configurationIsCached()) {
                $cmfConfig::getInstance()->updateAppConfigs($this->app);
            }
        }
    }

    /**
     * Used to register DB Record class names (Records are not singletons but class name is a singleton)
     * @param string $singletonName - singleton name in app's service container (should be a class name)
     * @param null|string $className - maps singleton to this class. When null - $singletonName is used as $className.
     *      Singleton will return a class name, not an instance of class
     */
    protected function registerClassNameSingleton($singletonName, $className = null) {
        if (empty($className)) {
            $className = $singletonName;
        }
        $this->app->singleton($singletonName, function () use ($className) {
            // note: do not create record here or you will possibly encounter infinite loop because this class may be
            // used in TableStructure via app(NameTableStructure) (for example to get default value, etc)
            return $className;
        });
    }

    /**
     * Used to register DB Table or DB TableStructure singleton instances
     * @param string $singletonName - singleton name in app's service container (should be a class name)
     * @param null|string|TableInterface|TableStructureInterface $classNameOrInstance - map this class/TableInterface
     *      to a singleton. When null - $singletonName is used as $className. Singleton will return an instance of class
     */
    protected function registerClassInstanceSingleton($singletonName, $classNameOrInstance = null) {
        if (empty($classNameOrInstance)) {
            $classNameOrInstance = $singletonName;
        }
        $this->app->singleton($singletonName, function () use ($classNameOrInstance) {
            /** @var TableInterface|TableStructureInterface $classNameOrInstance */
            return is_string($classNameOrInstance)
                ? $classNameOrInstance::getInstance()
                : $classNameOrInstance;
        });
    }

    protected function setDefaultCmfConfig() {
        $defaultConfig = $this->getAppConfigs()->get('peskycmf.default_cmf_config');
        if ($defaultConfig) {
            /** @var CmfConfig $className */
            $className = $this->getAppConfigs()->get('peskycmf.cmf_configs.' . $defaultConfig);
            if (!empty($className) && class_exists($className)) {
                $className::getInstance()->useAsDefault();
                $this->app->singleton(CmfConfig::class, function () {
                    return CmfConfig::getDefault();
                });
            }
        }
    }

}


<?php

namespace Ekyna\Component\Commerce\Common\Listener;

use Ekyna\Component\Commerce\Common\Model\NotificationTypes;
use Ekyna\Component\Commerce\Order\Model\OrderPaymentInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentStates;

/**
 * Class PaymentNotifyListener
 * @package Ekyna\Component\Commerce\Common\Listener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class PaymentNotifyListener extends AbstractNotifyListener
{
    /**
     * Post persist event handler.
     *
     * @param OrderPaymentInterface $payment
     */
    public function postPersist(OrderPaymentInterface $payment)
    {
        $this->watch($payment);
    }

    /**
     * Post update event handler.
     *
     * @param OrderPaymentInterface $payment
     */
    public function postUpdate(OrderPaymentInterface $payment)
    {
        $this->watch($payment);
    }

    /**
     * Payment watcher.
     *
     * @param OrderPaymentInterface $payment
     */
    protected function watch(OrderPaymentInterface $payment)
    {
        $order = $payment->getOrder();

        // Abort if notify disabled or sample order
        if (!$order->isAutoNotify() || $order->isSample()) {
            return;
        }

        // Abort if not manual/offline payment
        if (!$payment->getMethod()->isManual()) {
            return;
        }

        // Abort if payment state has not changed for 'CAPTURED'
        if (!$this->didStateChangeTo($payment, PaymentStates::STATE_CAPTURED)) {
            return;
        }

        // Abort if sale has notification of type 'PAYMENT_CAPTURED' with same payment number
        /** @var \Ekyna\Component\Commerce\Order\Model\OrderNotificationInterface $n */
        foreach ($order->getNotifications() as $n) {
            if ($n->getType() !== NotificationTypes::PAYMENT_CAPTURED) {
                continue;
            }

            if ($n->hasData('payment') && $n->getData('payment') === $payment->getNumber()) {
                return;
            }
        }

        $this->notify(NotificationTypes::PAYMENT_CAPTURED, $payment);
    }
}


<?php

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

use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use Ekyna\Component\Commerce\Customer\Model\CustomerInterface;
use Ekyna\Component\Commerce\Invoice\Model\InvoiceStates;
use Ekyna\Component\Commerce\Order\Model\OrderInterface;
use Ekyna\Component\Commerce\Order\Repository\OrderRepositoryInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentTermTriggers as Trigger;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentStates;

/**
 * Class OrderRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 *
 * @method OrderInterface|null findOneById($id)
 * @method OrderInterface|null findOneByKey($key)
 * @method OrderInterface|null findOneByNumber($number)
 */
class OrderRepository extends AbstractSaleRepository implements OrderRepositoryInterface
{
    /**
     * @inheritdoc
     */
    public function existsForCustomer(CustomerInterface $customer)
    {
        $qb = $this->createQueryBuilder('o');

        $id = $qb
            ->select('o.id')
            ->andWhere($qb->expr()->eq('o.customer', ':customer'))
            ->getQuery()
            ->setMaxResults(1)
            ->setParameters([
                'customer' => $customer,
            ])
            ->getOneOrNullResult(Query::HYDRATE_SINGLE_SCALAR);

        return null !== $id;
    }

    /**
     * @inheritdoc
     */
    public function existsForEmail(string $email)
    {
        $qb = $this->createQueryBuilder('o');

        $id = $qb
            ->select('o.id')
            ->andWhere($qb->expr()->eq('o.email', ':email'))
            ->getQuery()
            ->setMaxResults(1)
            ->setParameters([
                'email' => $email,
            ])
            ->getOneOrNullResult(Query::HYDRATE_SINGLE_SCALAR);

        return null !== $id;
    }

    /**
     * @inheritdoc
     */
    public function findOneByCustomerAndNumber(CustomerInterface $customer, $number)
    {
        $qb = $this->createQueryBuilder('o');

        $sale = $qb
            ->join('o.customer', 'c')
            ->andWhere($qb->expr()->orX(
                $qb->expr()->eq('c', ':customer'),
                $qb->expr()->eq('c.parent', ':customer'),
                $qb->expr()->eq('o.originCustomer', ':customer')
            ))
            ->andWhere($qb->expr()->eq('o.number', ':number'))
            ->getQuery()
            ->setParameters([
                'customer' => $customer,
                'number'   => $number,
            ])
            ->getOneOrNullResult();

        if (null !== $sale) {
            $this
                ->loadLines($sale)
                ->loadPayments($sale);
        }

        return $sale;
    }

    /**
     * @inheritdoc
     */
    public function findByOriginCustomer(CustomerInterface $customer, array $states = [], $strict = false)
    {
        $qb = $this->createQueryBuilder('o');

        if ($strict) {
            $qb->andWhere($qb->expr()->eq('o.originCustomer', ':customer'));
        } else {
            $qb->andWhere($qb->expr()->orX(
                $qb->expr()->eq('o.customer', ':customer'),
                $qb->expr()->eq('o.originCustomer', ':customer')
            ));
        }

        $qb->addOrderBy('o.createdAt', 'DESC');

        $parameters = ['customer' => $customer];

        if (!empty($states)) {
            $qb->andWhere($qb->expr()->in('o.state', ':states'));

            $parameters['states'] = $states;
        }

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

    /**
     * @inheritdoc
     */
    public function findDueOrders()
    {
        $qb = $this->createQueryBuilder('o');
        $ex = $qb->expr();

        $query = $qb
            ->leftJoin('o.paymentTerm', 't')
            ->andWhere($ex->eq('o.sample', ':not_sample'))// Not sample
            ->andWhere($ex->lt('o.paidTotal', 'o.grandTotal'))// Paid total lower than grand total
            ->andWhere($ex->orX(
                $ex->andX(                                               // Outstanding
                    $ex->isNotNull('o.paymentTerm'),                     // - With payment term
                    $this->getDueClauses(),                              // - Terms triggered
                    $qb->expr()->lte('o.outstandingDate',
                        ':today')      // - Payment limit date lower than or equal today
                ),
                $ex->andX(                                               // Regular
                    $ex->isNull('o.paymentTerm'),                        // - Without payment term
                    $ex->eq('o.shipmentState', ':state_fully_shipped')   // - Shipped
                )
            ))
            ->addOrderBy('o.outstandingDate', 'ASC')
            ->getQuery();

        $this->setDueParameters($query);

        return $query
            ->setParameter('not_sample', false)
            ->setParameter('today', (new \DateTime())->setTime(23, 59, 59), Type::DATETIME)
            ->useQueryCache(true)
            ->getResult();
    }

    /**
     * @inheritdoc
     */
    public function getRegularDue()
    {
        return $this
            ->getRegularDueQueryBuilder()
            ->select('SUM(o.grandTotal)-SUM(o.paidTotal)')
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getSingleScalarResult();
    }

    /**
     * @inheritdoc
     */
    public function getRegularDueOrders()
    {
        return $this
            ->getRegularDueQueryBuilder()
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getResult();
    }

    /**
     * @inheritdoc
     */
    public function getOutstandingExpiredDue()
    {
        return $this
            ->getOutstandingExpiredDueQueryBuilder()
            ->select('SUM(o.grandTotal)-SUM(o.paidTotal)')
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getSingleScalarResult();
    }

    /**
     * @inheritdoc
     */
    public function getOutstandingExpiredDueOrders()
    {
        return $this
            ->getOutstandingExpiredDueQueryBuilder()
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getResult();
    }

    /**
     * @inheritDoc
     */
    public function getOutstandingFallDue()
    {
        return $this
            ->getOutstandingFallDueQueryBuilder()
            ->select('SUM(o.grandTotal)-SUM(o.paidTotal)')
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getSingleScalarResult();
    }

    /**
     * @inheritDoc
     */
    public function getOutstandingFallDueOrders()
    {
        return $this
            ->getOutstandingFallDueQueryBuilder()
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300)
            ->getResult();
    }

    /**
     * @inheritdoc
     */
    public function getOutstandingPendingDue()
    {
        return $this
            ->getOutstandingPendingDueQueryBuilder()
            ->select('SUM(o.grandTotal)-SUM(o.paidTotal)')
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300);
            ->getSingleScalarResult();
    }

    /**
     * @inheritdoc
     */
    public function getOutstandingPendingDueOrders()
    {
        return $this
            ->getOutstandingPendingDueQueryBuilder()
            ->getQuery()
            ->useQueryCache(true)
            //->useResultCache(true, 300);
            ->getResult();
    }

    /**
     * Returns the regular due query.
     *
     * @return QueryBuilder
     */
    private function getRegularDueQueryBuilder()
    {
        $qb = $this->createQueryBuilder('o');
        $ex = $qb->expr();

        return $qb
            ->where($ex->andX(
                $ex->eq('o.sample', ':not_sample'),                    // Not sample
                $ex->lt('o.paidTotal', 'o.grandTotal'),                // Paid total lower than grand total
                $ex->notIn('o.invoiceState', ':canceled_or_refunded'), // Not canceled/refunded
                $ex->eq('o.shipmentState', ':shipped'),                // Shipped
                $ex->isNull('o.paymentTerm')                           // Without payment term
            ))
            ->addOrderBy('o.createdAt', 'ASC')
            ->setParameter('not_sample', false)
            ->setParameter('shipped', ShipmentStates::STATE_COMPLETED)
            ->setParameter('canceled_or_refunded', [InvoiceStates::STATE_CANCELED, InvoiceStates::STATE_CREDITED]);
    }

    /**
     * Returns the outstanding expired due query builder.
     *
     * @return QueryBuilder
     */
    private function getOutstandingExpiredDueQueryBuilder()
    {
        $qb = $this->createQueryBuilder('o');
        $ex = $qb->expr();

        $qb
            ->join('o.paymentTerm', 't')
            ->where($ex->andX(
                $ex->eq('o.sample', ':not_sample'),                    // Not sample
                $ex->lt('o.paidTotal', 'o.grandTotal'),                // Paid total lower than grand total
                $ex->notIn('o.invoiceState', ':canceled_or_refunded'), // Not canceled/refunded
                $qb->expr()->lte('o.outstandingDate', ':today'),       // Payment limit date lower than today
                $this->getDueClauses()                                 // Terms triggered
            ))
            ->addOrderBy('o.outstandingDate', 'ASC')
            ->setParameter('not_sample', false)
            ->setParameter('today', (new \DateTime())->setTime(23, 59, 59), Type::DATETIME)
            ->setParameter('canceled_or_refunded', [InvoiceStates::STATE_CANCELED, InvoiceStates::STATE_CREDITED]);

        $this->setDueParameters($qb);

        return $qb;
    }

    /**
     * Returns the outstanding fall due query builder.
     *
     * @return QueryBuilder
     */
    private function getOutstandingFallDueQueryBuilder()
    {
        $qb = $this->createQueryBuilder('o');
        $ex = $qb->expr();

        $qb
            ->join('o.paymentTerm', 't')
            ->where($ex->andX(
                $ex->eq('o.sample', ':not_sample'),                    // Not sample
                $ex->lt('o.paidTotal', 'o.grandTotal'),                // Paid total lower than grand total
                $ex->notIn('o.invoiceState', ':canceled_or_refunded'), // Not canceled/refunded
                $qb->expr()->gt('o.outstandingDate', ':today'),        // Payment limit date greater than today
                $this->getDueClauses()                                 // Terms triggered
            ))
            ->addOrderBy('o.outstandingDate', 'ASC')
            ->setParameter('not_sample', false)
            ->setParameter('today', (new \DateTime())->setTime(23, 59, 59), Type::DATETIME)
            ->setParameter('canceled_or_refunded', [InvoiceStates::STATE_CANCELED, InvoiceStates::STATE_CREDITED]);

        $this->setDueParameters($qb);

        return $qb;
    }

    /**
     * @inheritdoc
     */
    private function getOutstandingPendingDueQueryBuilder()
    {
        $qb = $this->createQueryBuilder('o');
        $ex = $qb->expr();

        $qb
            ->join('o.paymentTerm', 't')
            ->where($ex->andX(
                $ex->eq('o.sample', ':not_sample'),                    // Not sample
                $ex->lt('o.paidTotal', 'o.grandTotal'),                // Paid total lower than grand total
                $ex->notIn('o.invoiceState', ':canceled_or_refunded'), // Not canceled/refunded
                $ex->not($this->getDueClauses())                       // Terms not triggered
            ))
            ->addOrderBy('o.createdAt', 'ASC')
            ->setParameter('not_sample', false)
            ->setParameter('canceled_or_refunded', [InvoiceStates::STATE_CANCELED, InvoiceStates::STATE_CREDITED]);

        $this->setDueParameters($qb);

        return $qb;
    }

    /**
     * Returns the due clause:
     * - payment term triggered
     * - payment date lower than or equal today
     *
     * @return Expr\Base
     */
    private function getDueClauses()
    {
        $ex = new Expr();

        return $ex->orX(
            $ex->andX(
                $ex->eq('t.trigger', ':trigger_invoiced'),
                $ex->in('o.invoiceState', ':state_invoiced')
            ),
            $ex->andX(
                $ex->eq('t.trigger', ':trigger_fully_invoiced'),
                $ex->eq('o.invoiceState', ':state_fully_invoiced')
            ),
            $ex->andX(
                $ex->eq('t.trigger', ':trigger_shipped'),
                $ex->in('o.shipmentState', ':state_shipped')
            ),
            $ex->andX(
                $ex->eq('t.trigger', ':trigger_fully_shipped'),
                $ex->eq('o.shipmentState', ':state_fully_shipped')
            )
        );
    }

    /**
     * Set the due clause's parameters.
     *
     * @param Query|QueryBuilder $query
     */
    private function setDueParameters($query)
    {
        $query
            ->setParameter('trigger_invoiced', Trigger::TRIGGER_INVOICED)
            ->setParameter('state_invoiced', [InvoiceStates::STATE_PARTIAL, InvoiceStates::STATE_COMPLETED])
            ->setParameter('trigger_fully_invoiced', Trigger::TRIGGER_FULLY_INVOICED)
            ->setParameter('state_fully_invoiced', InvoiceStates::STATE_COMPLETED)
            ->setParameter('trigger_shipped', Trigger::TRIGGER_SHIPPED)
            ->setParameter('state_shipped', [ShipmentStates::STATE_PARTIAL, ShipmentStates::STATE_COMPLETED])
            ->setParameter('trigger_fully_shipped', Trigger::TRIGGER_FULLY_SHIPPED)
            ->setParameter('state_fully_shipped', ShipmentStates::STATE_COMPLETED);
    }

    /**
     * @inheritdoc
     */
    protected function getAlias()
    {
        return 'o';
    }
}


<?php

namespace Ekyna\Component\Commerce\Document\Model;

use Doctrine\Common\Collections\ArrayCollection;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;

/**
 * Class Document
 * @package Ekyna\Component\Commerce\Common\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Document implements DocumentInterface
{
    /**
     * @var string
     */
    protected $type;

    /**
     * @var string
     */
    protected $currency;

    /**
     * @var array
     */
    protected $customer;

    /**
     * @var array
     */
    protected $invoiceAddress;

    /**
     * @var array
     */
    protected $deliveryAddress;

    /**
     * @var array
     */
    protected $relayPoint;

    /**
     * @var ArrayCollection|DocumentLineInterface[]
     */
    protected $lines;

    /**
     * @var string
     */
    protected $comment;

    /**
     * @var string
     */
    protected $description;

    /**
     * @var float
     */
    protected $goodsBase;

    /**
     * @var float
     */
    protected $discountBase;

    /**
     * @var float
     */
    protected $shipmentBase;

    /**
     * @var float
     */
    protected $taxesTotal;

    /**
     * @var array
     */
    protected $taxesDetails;

    /**
     * @var float
     */
    protected $grandTotal;

    /**
     * @var SaleInterface
     */
    protected $sale;

    /**
     * Constructor.
     */
    public function __construct()
    {
        $this->lines = new ArrayCollection();
    }

    /**
     * @inheritdoc
     */
    public function getType()
    {
        return $this->type;
    }

    /**
     * @inheritdoc
     */
    public function setType($type)
    {
        $this->type = $type;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getCurrency()
    {
        return $this->currency;
    }

    /**
     * @inheritdoc
     */
    public function setCurrency($currency)
    {
        $this->currency = $currency;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getCustomer()
    {
        return $this->customer;
    }

    /**
     * @inheritdoc
     */
    public function setCustomer(array $data)
    {
        $this->customer = $data;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getInvoiceAddress()
    {
        return $this->invoiceAddress;
    }

    /**
     * @inheritdoc
     */
    public function setInvoiceAddress(array $data)
    {
        $this->invoiceAddress = $data;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getDeliveryAddress()
    {
        return $this->deliveryAddress;
    }

    /**
     * @inheritdoc
     */
    public function setDeliveryAddress(array $data = null)
    {
        $this->deliveryAddress = $data;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getRelayPoint()
    {
        return $this->relayPoint;
    }

    /**
     * @inheritdoc
     */
    public function setRelayPoint(array $data = null)
    {
        $this->relayPoint = $data;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function hasLines()
    {
        return 0 < $this->lines->count();
    }

    /**
     * @inheritdoc
     */
    public function getLines()
    {
        return $this->lines;
    }

    /**
     * @inheritdoc
     */
    public function getLinesByType($type)
    {
        if (!DocumentLineTypes::isValidType($type)) {
            throw new InvalidArgumentException("Invalid document line type.");
        }

        $lines = [];

        foreach ($this->getLines() as $line) {
            if ($line->getType() === $type) {
                $lines[] = $line;
            }
        }

        return $lines;
    }

    /**
     * @inheritdoc
     */
    public function hasLine(DocumentLineInterface $line)
    {
        return $this->lines->contains($line);
    }

    /**
     * @inheritdoc
     */
    public function hasLineByType($type)
    {
        if (!DocumentLineTypes::isValidType($type)) {
            throw new InvalidArgumentException("Invalid document line type.");
        }

        foreach ($this->getLines() as $line) {
            if ($line->getType() === $type) {
                return true;
            }
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function addLine(DocumentLineInterface $line)
    {
        if (!$this->hasLine($line)) {
            $this->lines->add($line);
            $line->setDocument($this);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function removeLine(DocumentLineInterface $line)
    {
        if ($this->hasLine($line)) {
            $this->lines->removeElement($line);
            $line->setDocument(null);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function setLines(ArrayCollection $lines)
    {
        foreach ($this->lines as $line) {
            if (!$lines->contains($line)) {
                $this->removeLine($line);
            }
        }

        $this->lines = new ArrayCollection();

        foreach ($lines as $line) {
            $this->addLine($line);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getComment()
    {
        return $this->comment;
    }

    /**
     * @inheritdoc
     */
    public function setComment($comment)
    {
        $this->comment = $comment;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * @inheritdoc
     */
    public function setDescription($description)
    {
        $this->description = $description;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getGoodsBase(bool $ati = false)
    {
        return $ati ? $this->ati($this->goodsBase) : $this->goodsBase;
    }

    /**
     * @inheritdoc
     */
    public function setGoodsBase($base)
    {
        $this->goodsBase = (float)$base;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getDiscountBase(bool $ati = false)
    {
        return $ati ? $this->ati($this->discountBase) : $this->discountBase;
    }

    /**
     * @inheritdoc
     */
    public function setDiscountBase($base)
    {
        $this->discountBase = (float)$base;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getShipmentBase(bool $ati = false)
    {
        return $ati ? $this->ati($this->shipmentBase) : $this->shipmentBase;
    }

    /**
     * @inheritdoc
     */
    public function setShipmentBase($base)
    {
        $this->shipmentBase = (float)$base;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getTaxesTotal()
    {
        return $this->taxesTotal;
    }

    /**
     * @inheritdoc
     */
    public function setTaxesTotal($total)
    {
        $this->taxesTotal = (float)$total;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getTaxesDetails()
    {
        return $this->taxesDetails;
    }

    /**
     * @inheritdoc
     */
    public function setTaxesDetails(array $details)
    {
        $this->taxesDetails = $details;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getGrandTotal()
    {
        return $this->grandTotal;
    }

    /**
     * @inheritdoc
     */
    public function setGrandTotal($total)
    {
        $this->grandTotal = (float)$total;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getSale()
    {
        return $this->sale;
    }

    /**
     * @inheritdoc
     */
    public function setSale(SaleInterface $sale = null)
    {
        $this->sale = $sale;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function hasLineDiscount()
    {
        foreach ($this->lines as $line) {
            if (0 != $line->getDiscount()) {
                return true;
            }
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function hasMultipleTaxes()
    {
        return 1 < count($this->taxesDetails);
    }

    /**
     * Adds the taxes to the given amount.
     *
     * @param float $amount
     *
     * @return float
     */
    private function ati(float $amount)
    {
        $result = $amount;

        foreach ($this->taxesDetails as $tax) {
            $result += $amount * $tax['rate'] / 100;
        }

        return Money::round($result, $this->currency);
    }

    /**
     * @inheritdoc
     */
    public function isAti()
    {
        return $this->getSale()->isAtiDisplayMode();
    }

    /**
     * @inheritDoc
     */
    public function getLocale(): ?string
    {
        if ($sale = $this->getSale()) {
            return $sale->getLocale();
        }

        return null;
    }
}