<?php

namespace Ekyna\Component\Commerce\Install;

use Doctrine\Common\Persistence\ObjectManager;
use Ekyna\Component\Commerce\Common\Entity\Country;
use Ekyna\Component\Commerce\Common\Model\CountryInterface;
use Ekyna\Component\Commerce\Common\Entity\Currency;
use Ekyna\Component\Commerce\Common\Model\CurrencyInterface;
use Ekyna\Component\Commerce\Customer\Repository\CustomerGroupRepositoryInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Pricing\Entity\Tax;
use Ekyna\Component\Commerce\Pricing\Entity\TaxGroup;
use Ekyna\Component\Commerce\Pricing\Entity\TaxRule;
use Symfony\Component\Intl\Intl;
use Symfony\Component\Yaml\Yaml;

/**
 * Class Installer
 * @package Ekyna\Component\Commerce\Install
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Installer
{
    /**
     * @var ObjectManager
     */
    private $manager;

    /**
     * @var string
     */
    private $customerGroupRepository;

    /**
     * @var callable
     */
    private $log;

    /**
     * Constructor.
     *
     * @param ObjectManager                    $manager
     * @param CustomerGroupRepositoryInterface $repository
     * @param mixed                            $logger
     */
    public function __construct(ObjectManager $manager, CustomerGroupRepositoryInterface $repository, $logger = null)
    {
        $this->manager = $manager;
        $this->customerGroupRepository = $repository;

        if (in_array('Symfony\Component\Console\Output\OutputInterface', class_implements($logger))) {
            $this->log = function ($name, $result) use ($logger) {
                /** @var \Symfony\Component\Console\Output\OutputInterface $logger */
                $logger->writeln(sprintf(
                    '- <comment>%s</comment> %s %s.',
                    $name,
                    str_pad('.', 44 - mb_strlen($name), '.', STR_PAD_LEFT),
                    $result
                ));
            };
        } else {
            $this->log = function ($name, $result) {
            };
        }
    }

    /**
     * Installs the given countries and currencies by codes.
     *
     * @param string $country  The default country code
     * @param string $currency The default currency code
     *
     * @throws \Exception
     */
    public function install($country = 'US', $currency = 'USD')
    {
        $this->installCountries($country);
        $this->installCurrencies($currency);
        $this->installTaxes($country);
        $this->installTaxGroups($country);
        $this->installTaxRules($country);
        $this->installCustomerGroups();
    }

    /**
     * Installs the countries.
     *
     * @param string $code The default country's code
     *
     * @throws \Exception
     */
    public function installCountries($code = 'US')
    {
        $countryNames = Intl::getRegionBundle()->getCountryNames();

        if (!isset($countryNames[$code])) {
            throw new InvalidArgumentException("Invalid default country code '$code'.");
        }

        asort($countryNames);

        $this->generate(Country::class, $countryNames, $code);
    }

    /**
     * Installs the currencies.
     *
     * @param string $code The default currency's code
     *
     * @throws \Exception
     */
    public function installCurrencies($code = 'USD')
    {
        $currencyNames = Intl::getCurrencyBundle()->getCurrencyNames();

        if (!isset($currencyNames[$code])) {
            throw new InvalidArgumentException("Invalid default currency code '$code'.");
        }

        asort($currencyNames);

        $this->generate(Currency::class, $currencyNames, $code);
    }

    /**
     * Installs the taxes for the given country codes.
     *
     * @param array $codes The country codes to load the taxes for
     */
    public function installTaxes($codes = ['US'])
    {
        $codes = (array)$codes;

        if (empty($codes)) {
            return;
        }

        $taxRepository = $this->manager->getRepository(Tax::class);

        foreach ($codes as $code) {
            $path = __DIR__ . '/data/' . $code . '_taxes.yml';
            if (!(file_exists($path) && is_readable($path))) {
                call_user_func($this->log, 'Taxes data', 'not found');
                continue;
            }

            $data = Yaml::parse(file_get_contents($path));
            if (!is_array($data) || empty($data)) {
                continue;
            }

            $country = $this
                ->manager
                ->getRepository(Country::class)
                ->findOneBy(['code' => $code]);
            if (null === $country) {
                continue;
            }

            foreach ($data as $datum) {
                $name = $datum['name'];
                $result = 'already exists';

                if (null === $taxRepository->findOneBy(['name' => $name])) {
                    $tax = new Tax();
                    $tax
                        ->setName($name)
                        ->setRate($datum['rate'])
                        ->setCountry($country);

                    $this->manager->persist($tax);

                    $result = 'done';
                }

                call_user_func($this->log, $name, $result);
            }
        }

        $this->manager->flush();
    }

    /**
     * Installs the default tax groups.
     *
     * @param array $codes The country codes to load the taxes for
     */
    public function installTaxGroups($codes = ['US'])
    {
        $codes = (array)$codes;

        if (empty($codes)) {
            return;
        }

        $taxGroupRepository = $this->manager->getRepository(TaxGroup::class);
        $taxRepository = $this->manager->getRepository(Tax::class);

        foreach ($codes as $code) {
            $path = __DIR__ . '/data/' . $code . '_tax_groups.yml';
            if (!(file_exists($path) && is_readable($path))) {
                call_user_func($this->log, 'Tax groups data', 'not found');
                continue;
            }

            $data = Yaml::parse(file_get_contents($path));
            if (!is_array($data) || empty($data)) {
                continue;
            }

            foreach ($data as $datum) {
                $name = $datum['name'];
                $result = 'already exists';

                if ($datum['default']) {
                    $taxGroup = $this
                        ->manager
                        ->getRepository(TaxGroup::class)
                        ->findOneBy(['default' => true]);
                    if (null !== $taxGroup) {
                        call_user_func($this->log, $name, 'skipped');
                        continue;
                    }
                }

                if (null === $taxGroupRepository->findOneBy(['name' => $name])) {
                    $taxGroup = new TaxGroup();
                    $taxGroup
                        ->setName($name)
                        ->setDefault($datum['default']);

                    if (!empty($taxNames = $datum['taxes'])) {
                        $taxGroup->setTaxes($taxRepository->findBy(['name' => $taxNames]));
                    }

                    $this->manager->persist($taxGroup);

                    $result = 'done';
                }

                call_user_func($this->log, $name, $result);
            }
        }

        $this->manager->flush();
    }

    /**
     * Installs the tax rules for the given country codes.
     *
     * @param array $codes The country codes to load the tax rules for
     */
    public function installTaxRules($codes = ['US'])
    {
        $codes = (array)$codes;

        if (empty($codes)) {
            return;
        }

        $countryRepository = $this->manager->getRepository(Country::class);
        $taxRepository = $this->manager->getRepository(Tax::class);
        $taxRuleRepository = $this->manager->getRepository(TaxRule::class);

        foreach ($codes as $code) {
            $path = __DIR__ . '/data/' . $code . '_tax_rules.yml';
            if (!(file_exists($path) && is_readable($path))) {
                call_user_func($this->log, 'Tax rules data', 'not found');
                continue;
            }

            $data = Yaml::parse(file_get_contents($path));
            if (!is_array($data) || empty($data)) {
                continue;
            }

            foreach ($data as $datum) {
                $name = $datum['name'];
                $result = 'already exists';

                if (null === $taxRuleRepository->findOneBy(['name' => $name])) {
                    $taxRule = new TaxRule();
                    $taxRule
                        ->setName($name)
                        ->setPriority($datum['priority'])
                        ->setCustomer($datum['customer'])
                        ->setBusiness($datum['business'])
                        ->setNotices($datum['notices']);

                    if (!empty($countryCodes = $datum['countries'])) {
                        $taxRule->setCountries($countryRepository->findBy(['code' => $countryCodes]));
                    }

                    if (!empty($taxNames = $datum['taxes'])) {
                        $taxRule->setTaxes($taxRepository->findBy(['name' => $taxNames]));
                    }

                    $this->manager->persist($taxRule);

                    $result = 'done';
                }

                call_user_func($this->log, $name, $result);
            }
        }

        $this->manager->flush();
    }

    /**
     * Installs the default customer groups.
     */
    public function installCustomerGroups()
    {
        $groups = (array) $this->customerGroupRepository->findBy([], [], 1)->getIterator();
        if (!empty($groups)) {
            call_user_func($this->log, 'All', 'skipped');
            return;
        }

        $groups = [
            'Particuliers' => [
                'default'      => true,
                'business'     => false,
                'registration' => true,
            ],
            'Entreprise'   => [
                'default'      => false,
                'business'     => true,
                'registration' => true,
            ],
        ];

        foreach ($groups as $name => $config) {
            $result = 'already exists';
            if (null === $this->customerGroupRepository->findOneBy(['name' => $name])) {
                /** @var \Ekyna\Component\Commerce\Customer\Model\CustomerGroupInterface $customerGroup */
                $customerGroup = $this->customerGroupRepository->createNew();
                $customerGroup
                    ->setName($name)
                    ->setDefault($config['default'])
                    ->setBusiness($config['business'])
                    ->setRegistration($config['registration'])
                    ->translate()
                    ->setTitle($name);

                $this->manager->persist($customerGroup);

                $result = 'done';
            }

            call_user_func($this->log, $name, $result);
        }

        $this->manager->flush();
    }

    /**
     * Generates the entities.
     *
     * @param string $class
     * @param array  $names
     * @param string $defaultCode
     */
    private function generate($class, array $names, $defaultCode)
    {
        /** @var \Ekyna\Component\Resource\Doctrine\ORM\ResourceRepositoryInterface $repository */
        $repository = $this->manager->getRepository($class);

        foreach ($names as $code => $name) {
            $result = 'already exists';
            if (null === $repository->findOneBy(['code' => $code])) {
                /** @var CountryInterface|CurrencyInterface $entity */
                $entity = $repository->createNew();
                $entity
                    ->setName($name)
                    ->setCode($code)
                    ->setEnabled($defaultCode === $code);

                $this->manager->persist($entity);

                $result = 'done';
            }
            call_user_func($this->log, $name, $result);
        }

        $this->manager->flush();
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Util;

use Ekyna\Component\Commerce\Common\Context\ContextInterface;
use Ekyna\Component\Commerce\Common\Currency\CurrencyProviderInterface;
use Ekyna\Component\Resource\Locale\LocaleProviderInterface;

/**
 * Class FormatterFactory
 * @package Ekyna\Component\Commerce\Common\Util
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class FormatterFactory
{
    /**
     * @var LocaleProviderInterface
     */
    protected $localeProvider;

    /**
     * @var CurrencyProviderInterface
     */
    protected $currencyProvider;

    /**
     * @var Formatter[]
     */
    protected $cache;

    /**
     * Constructor.
     *
     * @param LocaleProviderInterface   $localeProvider
     * @param CurrencyProviderInterface $currencyProvider
     */
    public function __construct(LocaleProviderInterface $localeProvider, CurrencyProviderInterface $currencyProvider)
    {
        $this->localeProvider = $localeProvider;
        $this->currencyProvider = $currencyProvider;
        $this->cache = [];
    }

    /**
     * Creates a new formatter.
     *
     * @param string|null $locale
     * @param string|null $currency
     *
     * @return Formatter
     */
    public function create(string $locale = null, string $currency = null)
    {
        $locale = $locale ?? $this->localeProvider->getCurrentLocale();
        $currency = $currency ?? $this->currencyProvider->getCurrentCurrency();

        if (isset($this->cache[$key = strtolower("$locale-$currency")])) {
            return $this->cache[$key];
        }

        return $this->cache[$key] = new Formatter($locale, $currency);
    }

    /**
     * Returns the default formatter.
     *
     * @return Formatter
     */
    public function createDefault()
    {
        return $this->create(
            $this->localeProvider->getFallbackLocale(),
            $this->currencyProvider->getFallbackCurrency()
        );
    }

    /**
     * Returns the formatter for the given context.
     *
     * @param ContextInterface $context
     *
     * @return Formatter
     */
    public function createFromContext(ContextInterface $context)
    {
        return $this->create($context->getLocale(), $context->getCurrency()->getCode());
    }
}


<?php declare(strict_types=1);

namespace Ekyna\Component\Commerce\Common\Util;

use Ekyna\Component\Commerce\Common\Calculator\Adjustment;
use IntlDateFormatter;
use NumberFormatter;

/**
 * Class Formatter
 * @package Ekyna\Component\Commerce\Common\Util
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Formatter
{
    /**
     * @var string
     */
    private $locale;

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

    /**
     * @var IntlDateFormatter
     */
    private $dateFormatter;

    /**
     * @var IntlDateFormatter
     */
    private $dateTimeFormatter;

    /**
     * @var NumberFormatter
     */
    private $numberFormatter;

    /**
     * @var NumberFormatter
     */
    private $currencyFormatter;

    /**
     * Constructor.
     *
     * @param string $locale
     * @param string $currency
     */
    public function __construct(string $locale = 'FR', string $currency = 'EUR')
    {
        $this->locale = $locale;
        $this->currency = $currency;
    }

    /**
     * Returns the locale.
     *
     * @return string
     */
    public function getLocale(): string
    {
        return $this->locale;
    }

    /**
     * Returns the currency.
     *
     * @return string
     */
    public function getCurrency(): string
    {
        return $this->currency;
    }

    /**
     * Formats the given date for display.
     *
     * @param \DateTime $date
     *
     * @return string
     */
    public function date(\DateTime $date): string
    {
        //$this->dateFormatter->getTimeZone()
        //if ($this->dateFormatter->getTimeZone() === $date->getTimezone();

        return $this->getDateFormatter()->format($date->getTimestamp());
    }

    /**
     * Formats the given date time for display.
     *
     * @param \DateTime $date
     *
     * @return string
     */
    public function dateTime(\DateTime $date): string
    {
        //$this->dateFormatter->getTimeZone()
        //if ($this->dateFormatter->getTimeZone() === $date->getTimezone();

        return $this->getDateTimeFormatter()->format($date->getTimestamp());
    }

    /**
     * Formats the given number for display.
     *
     * @param float $number
     *
     * @return string
     */
    public function number(float $number): string
    {
        return $this->getNumberFormatter()->format($number, NumberFormatter::TYPE_DEFAULT);
    }

    /**
     * Formats the given currency number for display.
     *
     * @param float  $number
     * @param string $currency
     *
     * @return string
     */
    public function currency(float $number, string $currency = null): string
    {
        return $this->getCurrencyFormatter()->formatCurrency($number, $currency ? $currency : $this->currency);
    }

    /**
     * Formats the given percent number for display.
     *
     * @param float $number
     *
     * @return string
     */
    public function percent(float $number): string
    {
        return $this->getNumberFormatter()->format($number, NumberFormatter::TYPE_DEFAULT) . '%';
    }

    /**
     * Formats the given adjustments rates for display.
     *
     * @param Adjustment[] $adjustments
     *
     * @return string
     */
    public function rates(Adjustment ...$adjustments): string
    {
        return implode(', ', array_map(function (Adjustment $adjustment) {
            return $this->percent($adjustment->getRate());
        }, $adjustments));
    }

    /**
     * Returns the date formatter.
     *
     * @return IntlDateFormatter
     */
    private function getDateFormatter()
    {
        if ($this->dateFormatter) {
            return $this->dateFormatter;
        }

        return $this->dateFormatter = IntlDateFormatter::create(
            $this->locale,
            IntlDateFormatter::SHORT,
            IntlDateFormatter::NONE,
            ini_get('date.timezone'),
            //PHP_VERSION_ID >= 50500 ? $date->getTimezone() : $date->getTimezone()->getName(),
            IntlDateFormatter::GREGORIAN
        );
    }

    /**
     * Returns the date time formatter.
     *
     * @return IntlDateFormatter
     */
    private function getDateTimeFormatter()
    {
        if ($this->dateTimeFormatter) {
            return $this->dateTimeFormatter;
        }

        return $this->dateTimeFormatter = IntlDateFormatter::create(
            $this->locale,
            IntlDateFormatter::SHORT,
            IntlDateFormatter::SHORT,
            ini_get('date.timezone'),
            //PHP_VERSION_ID >= 50500 ? $date->getTimezone() : $date->getTimezone()->getName(),
            IntlDateFormatter::GREGORIAN
        );
    }

    /**
     * Returns the number formatter.
     *
     * @return NumberFormatter
     */
    private function getNumberFormatter()
    {
        if ($this->numberFormatter) {
            return $this->numberFormatter;
        }

        return $this->numberFormatter = NumberFormatter::create($this->locale, NumberFormatter::DECIMAL);
    }

    /**
     * Returns the currency formatter.
     *
     * @return NumberFormatter
     */
    private function getCurrencyFormatter()
    {
        if ($this->currencyFormatter) {
            return $this->currencyFormatter;
        }

        return $this->currencyFormatter = NumberFormatter::create($this->locale, NumberFormatter::CURRENCY);
    }
}


<?php

namespace Ekyna\Component\Commerce\Supplier\Export;

use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Commerce\Common\Util\FormatterFactory;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderInterface;
use Ekyna\Component\Commerce\Supplier\Repository\SupplierOrderRepositoryInterface;

/**
 * Class SupplierOrderExporter
 * @package Ekyna\Component\Commerce\Supplier\Export
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierOrderExporter
{
    use FormatterAwareTrait;

    /**
     * @var SupplierOrderRepositoryInterface
     */
    protected $repository;

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

    /**
     * Export the suppliers expired due orders.
     *
     * @return string The export file path.
     */
    public function exportSuppliersExpiredDueOrders()
    {
        return $this->buildFile($this->repository->findSuppliersExpiredDue(), 'suppliers_expired');
    }

    /**
     * Export the suppliers fall due orders.
     *
     * @return string The export file path.
     */
    public function exportSuppliersFallDueOrders()
    {
        return $this->buildFile($this->repository->findSuppliersFallDue(), 'suppliers_fall');
    }

    /**
     * Export the forwarders expired due orders.
     *
     * @return string The export file path.
     */
    public function exportForwardersExpiredDueOrders()
    {
        return $this->buildFile($this->repository->findForwardersExpiredDue(), 'suppliers_expired');
    }

    /**
     * Export the forwarders fall due orders.
     *
     * @return string The export file path.
     */
    public function exportForwardersFallDueOrders()
    {
        return $this->buildFile($this->repository->findForwardersFallDue(), 'suppliers_fall');
    }

    /**
     * Builds the orders export CSV file.
     *
     * @param SupplierOrderInterface[] $orders
     * @param string                   $name
     *
     * @return string
     */
    protected function buildFile(array $orders, string $name)
    {
        if (false === $path = tempnam(sys_get_temp_dir(), $name)) {
            throw new RuntimeException("Failed to create temporary file.");
        }

        if (false === $handle = fopen($path, "w")) {
            throw new RuntimeException("Failed to open '$path' for writing.");
        }

        if (!empty($headers = $this->buildHeaders())) {
            fputcsv($handle, $headers, ';', '"');
        }

        $supplierTotal = 0;
        $forwarderTotal = 0;

        // Order rows
        foreach ($orders as $order) {
            if (!empty($row = $this->buildRow($order))) {
                fputcsv($handle, $row, ';', '"');

                $supplierTotal += $row['payment_total'];
                $forwarderTotal += $row['forwarder_total'];
            }
        }

        // Total row
        fputcsv($handle, [
            'id'                 => '',
            'number'             => '',
            'state'              => '',
            'ordered_at'         => '',
            'completed_at'       => '',
            'supplier'           => '',
            'payment_total'      => $supplierTotal,
            'payment_date'       => '',
            'payment_due_date'   => '',
            'carrier'            => '',
            'forwarder_total'    => $forwarderTotal,
            'forwarder_date'     => '',
            'forwarder_due_date' => '',
        ], ';', '"');

        fclose($handle);

        return $path;
    }

    /**
     * Returns the headers.
     *
     * @return array
     */
    protected function buildHeaders()
    {
        return [
            'id',
            'number',
            'state',
            'ordered_at',
            'completed_at',
            'supplier',
            'payment_total',
            'payment_date',
            'payment_due_date',
            'carrier',
            'forwarder_total',
            'forwarder_date',
            'forwarder_due_date',
        ];
    }

    /**
     * Builds the order row.
     *
     * @param SupplierOrderInterface $order
     *
     * @return array
     */
    protected function buildRow(SupplierOrderInterface $order)
    {
        $date = null;
        $term = null;

        $formatter = $this->getFormatter();

        if (null !== $orderedAt = $order->getOrderedAt()) {
            $orderedAt = $formatter->date($orderedAt);
        }
        if (null !== $completedAt = $order->getCompletedAt()) {
            $completedAt = $formatter->date($completedAt);
        }
        if (null !== $paymentDate = $order->getPaymentDate()) {
            $paymentDate = $formatter->date($paymentDate);
        }
        if (null !== $paymentDueDate = $order->getPaymentDueDate()) {
            $paymentDueDate = $formatter->date($paymentDueDate);
        }
        if (null !== $carrier = $order->getCarrier()) {
            $carrier = $carrier->getName();
        }
        if (null !== $forwarderDate = $order->getForwarderDate()) {
            $forwarderDate = $formatter->date($forwarderDate);
        }
        if (null !== $forwarderDueDate = $order->getForwarderDueDate()) {
            $forwarderDueDate = $formatter->date($forwarderDueDate);
        }

        return [
            'id'                 => $order->getId(),
            'number'             => $order->getNumber(),
            'state'              => $order->getState(),
            'ordered_at'         => $orderedAt,
            'completed_at'       => $completedAt,
            'supplier'           => $order->getSupplier()->getName(),
            'payment_total'      => $order->getPaymentTotal(),
            'payment_date'       => $paymentDate,
            'payment_due_date'   => $paymentDueDate,
            'carrier'            => $carrier,
            'forwarder_total'    => $order->getForwarderTotal(),
            'forwarder_date'     => $forwarderDate,
            'forwarder_due_date' => $forwarderDueDate,
        ];
    }
}


<?php declare(strict_types=1);

namespace Ekyna\Component\Commerce\Common\Calculator;

/**
 * Class Discount
 * @package Ekyna\Component\Commerce\Common\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Adjustment
{
    /**
     * @var string
     */
    private $name;

    /**
     * @var float
     */
    private $amount;

    /**
     * @var float
     */
    private $rate;

    /**
     * Constructor.
     *
     * @param string $name
     * @param float  $amount
     * @param float  $rate
     */
    public function __construct(string $name, float $amount, float $rate = null)
    {
        $this->name = $name;
        $this->amount = $amount;
        $this->rate = $rate;
    }

    /**
     * Returns the name.
     *
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * Returns the amount.
     *
     * @return float
     */
    public function getAmount(): float
    {
        return $this->amount;
    }

    /**
     * Returns the rate.
     *
     * @return float
     */
    public function getRate(): float
    {
        return $this->rate;
    }

    /**
     * Adds the amount.
     *
     * @param float $amount
     */
    public function addAmount(float $amount): void
    {
        $this->amount += $amount;
    }

    /**
     * Returns whether or not this adjustment is the same as the given one.
     *
     * @param Adjustment $adjustment
     *
     * @return bool
     */
    public function isSameAs(Adjustment $adjustment): bool
    {
        return $this->name === $adjustment->getName() && $this->rate === $adjustment->getRate();
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Builder;

use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Commerce\Common\Resolver\DiscountResolverInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Pricing\Resolver\TaxResolverInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

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

    /**
     * @var TaxResolverInterface
     */
    protected $taxResolver;

    /**
     * @var DiscountResolverInterface
     */
    protected $discountResolver;

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

    /**
     * Constructor.
     *
     * @param SaleFactoryInterface             $saleFactory
     * @param TaxResolverInterface             $taxResolver
     * @param DiscountResolverInterface         $discountResolver
     * @param PersistenceHelperInterface       $persistenceHelper
     */
    public function __construct(
        SaleFactoryInterface $saleFactory,
        TaxResolverInterface $taxResolver,
        DiscountResolverInterface $discountResolver,
        PersistenceHelperInterface $persistenceHelper
    ) {
        $this->saleFactory = $saleFactory;
        $this->taxResolver = $taxResolver;
        $this->discountResolver = $discountResolver;
        $this->persistenceHelper = $persistenceHelper;
    }

    /**
     * @inheritdoc
     */
    public function buildDiscountAdjustmentsForSale(Model\SaleInterface $sale, $persistence = false)
    {
        $data = $sale->isAutoDiscount() && !$sale->isSample() ? $this->discountResolver->resolveSale($sale) : [];

        return $this->buildAdjustments(Model\AdjustmentTypes::TYPE_DISCOUNT, $sale, $data, $persistence);
    }

    /**
     * @inheritdoc
     */
    public function buildDiscountAdjustmentsForSaleItems($parent, $persistence = false)
    {
        if ($parent instanceof Model\SaleInterface) {
            $children = $parent->getItems();
        } elseif ($parent instanceof Model\SaleItemInterface) {
            $children = $parent->getChildren();
        } else {
            throw new InvalidArgumentException("Expected instance of SaleInterface or SaleItemInterface.");
        }

        $change = false;

        foreach ($children as $child) {
            $change |= $this->buildDiscountAdjustmentsForSaleItem($child, $persistence);

            if ($child->hasChildren()) {
                $change |= $this->buildDiscountAdjustmentsForSaleItems($child, $persistence);
            }
        }

        return $change;
    }

    /**
     * @inheritdoc
     */
    public function buildDiscountAdjustmentsForSaleItem(Model\SaleItemInterface $item, $persistence = false)
    {
        $sale = $item->getSale();

        $data = $sale->isAutoDiscount() && !$sale->isSample() ? $this->discountResolver->resolveSaleItem($item) : [];

        return $this->buildAdjustments(Model\AdjustmentTypes::TYPE_DISCOUNT, $item, $data, $persistence);
    }

    /**
     * @inheritdoc
     */
    public function buildTaxationAdjustmentsForSale(Model\SaleInterface $sale, $persistence = false)
    {
        $data = [];

        // For now, we assume that sale's taxation adjustments are only related to shipment.
        if (!($sale->isTaxExempt() || $sale->isSample()) && null !== $taxable = $sale->getShipmentMethod()) {
            // Resolve taxes
            $data = $this->taxResolver->resolveTaxes($taxable, $sale);
        }

        return $this->buildAdjustments(Model\AdjustmentTypes::TYPE_TAXATION, $sale, $data, $persistence);
    }

    /**
     * @inheritdoc
     */
    public function buildTaxationAdjustmentsForSaleItems($parent, $persistence = false)
    {
        if ($parent instanceof Model\SaleInterface) {
            $children = $parent->getItems();
        } elseif ($parent instanceof Model\SaleItemInterface) {
            $children = $parent->getChildren();
        } else {
            throw new InvalidArgumentException("Expected instance of SaleInterface or SaleItemInterface.");
        }

        $change = false;

        foreach ($children as $child) {
            $change = $this->buildTaxationAdjustmentsForSaleItem($child, $persistence) || $change;

            if ($child->hasChildren()) {
                $change = $this->buildTaxationAdjustmentsForSaleItems($child, $persistence) || $change;
            }
        }

        return $change;
    }

    /**
     * @inheritdoc
     */
    public function buildTaxationAdjustmentsForSaleItem(Model\SaleItemInterface $item, $persistence = false)
    {
        $data = [];

        $sale = $item->getSale();
        if (!$item->isPrivate() && !(null === $sale || $sale->isTaxExempt() || $sale->isSample())) {
            $data = $this->taxResolver->resolveTaxes($item, $sale);
        }

        return $this->buildAdjustments(Model\AdjustmentTypes::TYPE_TAXATION, $item, $data, $persistence);
    }

    /**
     * Builds the adjustable's adjustments based on the given data and type.
     *
     * @param string                          $type
     * @param Model\AdjustableInterface       $adjustable
     * @param Model\AdjustmentDataInterface[] $data
     * @param bool                            $persistence
     *
     * @return bool Whether at least one adjustment has been changed (created, updated or deleted)
     */
    protected function buildAdjustments($type, Model\AdjustableInterface $adjustable, array $data, $persistence = false)
    {
        Model\AdjustmentTypes::isValidType($type);

        $change = false;

        // Generate adjustments
        $newAdjustments = [];
        foreach ($data as $d) {
            $adjustment = $this->saleFactory->createAdjustmentFor($adjustable);
            $adjustment
                ->setType($type)
                ->setMode($d->getMode())
                ->setDesignation($d->getDesignation())
                ->setAmount($d->getAmount())
                ->setImmutable($d->isImmutable());

            $newAdjustments[] = $adjustment;
        }

        // Current adjustments
        $oldAdjustments = $adjustable->getAdjustments($type);

        // Remove current adjustments that do not match any generated adjustments
        foreach ($oldAdjustments as $oldAdjustment) {
            // Skip non-immutable adjustment as they have been defined by the user.
            if (!$oldAdjustment->isImmutable()) {
                continue;
            }

            // Look for a corresponding adjustment
            foreach ($newAdjustments as $index => $newAdjustment) {
                if ($oldAdjustment->equals($newAdjustment)) {
                    // Remove the generated adjustment
                    unset($newAdjustments[$index]);
                    continue 2;
                }
            }

            // No matching generated adjustment found : remove the current.
            $adjustable->removeAdjustment($oldAdjustment);

            if ($persistence) {
                $this->persistenceHelper->remove($oldAdjustment, true);
            }

            $change = true;
        }

        // Adds the remaining generated adjustments
        foreach ($newAdjustments as $newAdjustment) {
            $adjustable->addAdjustment($newAdjustment);

            if ($persistence) {
                $this->persistenceHelper->persistAndRecompute($newAdjustment, true);
            }

            $change = true;
        }

        return $change;
    }
}


<?php

namespace Ekyna\Component\Commerce\Accounting\Export;

use Ekyna\Component\Commerce\Accounting\Model\AccountingInterface;
use Ekyna\Component\Commerce\Accounting\Model\AccountingTypes;
use Ekyna\Component\Commerce\Accounting\Repository\AccountingRepositoryInterface;
use Ekyna\Component\Commerce\Common\Model\AdjustmentModes;
use Ekyna\Component\Commerce\Common\Model\AdjustmentTypes;
use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Customer\Model\CustomerGroupInterface;
use Ekyna\Component\Commerce\Document\Model\DocumentLineTypes;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Invoice\Model\InvoiceInterface;
use Ekyna\Component\Commerce\Invoice\Model\InvoiceTypes;
use Ekyna\Component\Commerce\Invoice\Repository\InvoiceRepositoryInterface;
use Ekyna\Component\Commerce\Invoice\Resolver\InvoicePaymentResolverInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentMethodInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentStates;
use Ekyna\Component\Commerce\Payment\Repository\PaymentRepositoryInterface;
use Ekyna\Component\Commerce\Pricing\Model\TaxRuleInterface;
use Ekyna\Component\Commerce\Pricing\Resolver\TaxResolverInterface;

/**
 * Class AccountingExporter
 * @package Ekyna\Component\Commerce\Accounting\Export
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class AccountingExporter implements AccountingExporterInterface
{
    /**
     * @var InvoiceRepositoryInterface
     */
    protected $invoiceRepository;

    /**
     * @var PaymentRepositoryInterface
     */
    protected $paymentRepository;

    /**
     * @var AccountingRepositoryInterface
     */
    protected $accountingRepository;

    /**
     * @var InvoicePaymentResolverInterface
     */
    protected $invoicePaymentResolver;

    /**
     * @var TaxResolverInterface
     */
    protected $taxResolver;

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

    /**
     * @var AccountingInterface[]
     */
    protected $accounts;

    /**
     * @var AccountingWriter
     */
    protected $writer;

    /**
     * @var InvoiceInterface
     */
    protected $invoice;

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

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

    /**
     * Constructor.
     *
     * @param InvoiceRepositoryInterface      $invoiceRepository
     * @param PaymentRepositoryInterface      $paymentRepository
     * @param AccountingRepositoryInterface   $accountingRepository
     * @param InvoicePaymentResolverInterface $invoicePaymentResolver
     * @param TaxResolverInterface            $taxResolver
     * @param array                           $config
     */
    public function __construct(
        InvoiceRepositoryInterface $invoiceRepository,
        PaymentRepositoryInterface $paymentRepository,
        AccountingRepositoryInterface $accountingRepository,
        InvoicePaymentResolverInterface $invoicePaymentResolver,
        TaxResolverInterface $taxResolver,
        array $config
    ) {
        $this->invoiceRepository = $invoiceRepository;
        $this->paymentRepository = $paymentRepository;
        $this->accountingRepository = $accountingRepository;
        $this->invoicePaymentResolver = $invoicePaymentResolver;
        $this->taxResolver = $taxResolver;

        $this->config = array_replace([
            'default_customer' => '10000000',
            'total_as_payment' => false,
        ], $config);
    }

    /**
     * @inheritDoc
     */
    public function export(\DateTime $month)
    {
        $path = tempnam(sys_get_temp_dir(), 'acc');

        $zip = new \ZipArchive();

        if (false === $zip->open($path)) {
            throw new RuntimeException("Failed to open '$path' for writing.");
        }

        $zip->addFile($this->exportInvoices($month), 'invoices.csv');

        if (!$this->config['total_as_payment']) {
            $zip->addFile($this->exportPayments($month), 'payments.csv');
        }

        $zip->close();

        return $path;
    }

    /**
     * @inheritDoc
     */
    public function exportInvoices(\DateTime $month)
    {
        $this->accounts = $this->accountingRepository->findBy([
            'enabled' => true,
        ]);
        if (empty($this->accounts)) {
            throw new LogicException("No account number configured.");
        }

        $path = tempnam(sys_get_temp_dir(), 'inv');

        $this->writer = new AccountingWriter();
        $this->writer->open($path);

        $WRONG_BALANCES = [];

        $invoices = $this->invoiceRepository->findByMonth($month);

        while (false !== $this->invoice = current($invoices)) {
            $this->currency = $this->invoice->getCurrency();
            $this->balance = 0;

            $this->writer->configure($this->invoice);

            $this->writeInvoiceGrandTotal();
            $this->writeInvoiceGoodsLines();
            $this->writeInvoiceShipmentLine();
            $this->writeInvoiceTaxesLine();

            if (0 !== $this->compare($this->balance, 0)) {
                $WRONG_BALANCES[] = $this->invoice->getNumber();
            }

            next($invoices);
        }

        $this->writer->close();

        return $path;
    }

    /**
     * @inheritDoc
     */
    public function exportPayments(\DateTime $month)
    {
        $path = tempnam(sys_get_temp_dir(), 'acc');

        $this->writer = new AccountingWriter();
        $this->writer->open($path);

        $payments = $this->paymentRepository->findByMonth($month, [
            PaymentStates::STATE_CAPTURED,
            PaymentStates::STATE_COMPLETED,
            PaymentStates::STATE_REFUNDED,
        ]);

        foreach ($payments as $payment) {
            $method = $payment->getMethod();
            if ($method->isOutstanding()) {
                continue;
            }

            $this->writer->configure($payment);

            $account = $this->getPaymentAccountNumber($method, $payment->getNumber());

            if ($customer = $payment->getSale()->getCustomer()) {
                $number = '1' . str_pad($customer->getId(), '7', '0', STR_PAD_LEFT);
            } else {
                $number = '10000000';
            }

            $credit = false; // TODO $payment->getType() === PaymentTypes::TYPE_REFUND;
            $amount = (string)$this->round($payment->getAmount());
            $date = $payment->getSale()->getCreatedAt();

            if ($credit) {
                // Payment debit
                $this->writer->debit($account, $amount, $date);
                // Customer credit
                $this->writer->credit($number, $amount, $date);
            } else {
                // Payment credit
                $this->writer->credit($account, $amount, $date);
                // Customer debit
                $this->writer->debit($number, $amount, $date);
            }

            // TODO Remove when refund payment implemented
            // Temporary : add an extra credit line for refund payments.
            if ($payment->getState() === PaymentStates::STATE_REFUNDED) {
                // Payment debit
                $this->writer->debit($account, $amount, $date);
                // Customer credit
                $this->writer->credit($number, $amount, $date);
            }
        }

        $this->writer->close();

        return $path;
    }

    /**
     * Writes the invoice's grand total line(s).
     */
    protected function writeInvoiceGrandTotal()
    {
        $sale = $this->invoice->getSale();
        $date = $sale->getCreatedAt();

        // Grand total row
        if ($this->config['total_as_payment']) {

            // Credit case
            if ($this->invoice->getType() === InvoiceTypes::TYPE_CREDIT) {
                $account = $this->getPaymentAccountNumber(
                    $this->invoice->getPaymentMethod(),
                    $this->invoice->getNumber()
                );

                $amount = $this->round($this->invoice->getGrandTotal());

                $this->writer->debit($account, (string)$amount, $date);
                $this->balance += $amount;

                return;
            }

            // Invoice case
            $unpaid = $this->invoice->getGrandTotal();

            $payments = $this->invoicePaymentResolver->resolve($this->invoice);

            // Payments
            foreach ($payments as $payment) {
                $account = $this->getPaymentAccountNumber(
                    $payment->getPayment()->getMethod(),
                    $payment->getPayment()->getNumber()
                );

                $amount = $this->round($payment->getAmount());

                $this->writer->credit($account, (string)$amount, $date);

                $unpaid -= $amount;
                $this->balance -= $amount;
            }

            // Unpaid amount
            if (1 === $this->compare($unpaid, 0)) {
                $account = $this->getUnpaidAccountNumber($sale->getCustomerGroup(), $this->invoice->getNumber());

                $this->writer->credit($account, (string)$unpaid, $date);

                $this->balance -= $unpaid;
            }

            return;
        }

        if ($customer = $sale->getCustomer()) {
            $account = '1' . str_pad($customer->getId(), '7', '0', STR_PAD_LEFT);
        } else {
            $account = $this->config['default_customer'];
        }

        $amount = $this->round($this->invoice->getGrandTotal());

        if ($this->invoice->getType() === InvoiceTypes::TYPE_CREDIT) {
            $this->writer->debit($account, (string)$amount, $date);
            $this->balance += $amount;
        } else {
            $this->writer->credit($account, (string)$amount, $date);
            $this->balance -= $amount;
        }
    }

    /**
     * Writes the invoice's goods line.
     */
    protected function writeInvoiceGoodsLines()
    {
        $sale = $this->invoice->getSale();
        $date = $sale->getCreatedAt();
        $taxRule = $this->taxResolver->resolveSaleTaxRule($sale);
        /** @var \Ekyna\Component\Commerce\Common\Model\AdjustmentInterface[] $discounts */
        $discounts = $sale->getAdjustments(AdjustmentTypes::TYPE_DISCOUNT)->toArray();

        // Gather amounts by tax rates
        $amounts = [];
        foreach ($this->invoice->getLinesByType(DocumentLineTypes::TYPE_GOOD) as $line) {
            // Skip private lines
            if ($line->getSaleItem()->isPrivate()) {
                continue;
            }

            $rates = $line->getTaxRates();
            if (empty($rates)) {
                $rate = 0;
            } elseif (1 === count($rates)) {
                $rate = current($rates);
            } else {
                throw new LogicException("Multiple tax rates on goods lines are not yet supported."); // TODO
            }

            $amount = $line->getBase();

            // Apply sale's discounts
            if (!empty($discounts)) {
                $base = $amount;
                foreach ($discounts as $adjustment) {
                    if ($adjustment->getMode() === AdjustmentModes::MODE_PERCENT) {
                        $amount -= $this->round($amount * $adjustment->getAmount() / 100);
                    } else {
                        $amount -= $this->round($base / $this->invoice->getGoodsBase() * $adjustment->getAmount());
                    }
                }
            }

            if (!isset($amounts[(string)$rate])) {
                $amounts[(string)$rate] = 0;
            }

            $amounts[(string)$rate] += $this->round($amount);
        }

        $credit = $this->invoice->getType() === InvoiceTypes::TYPE_CREDIT;

        // Writes each tax rates's amount
        foreach ($amounts as $rate => $amount) {
            $amount = $this->round($amount);

            if (0 === $this->compare($amount, 0)) {
                continue; // next tax rate
            }

            $account = $this->getGoodAccountNumber($taxRule, (float)$rate, $this->invoice->getNumber());

            if ($credit) {
                $this->writer->credit($account, (string)$amount, $date);
                $this->balance -= $amount;
            } else {
                $this->writer->debit($account, (string)$amount, $date);
                $this->balance += $amount;
            }
        }
    }

    /**
     * Writes the invoice's shipment line.
     */
    protected function writeInvoiceShipmentLine()
    {
        $amount = $this->invoice->getShipmentBase();

        if (0 === $this->compare($amount, 0)) {
            return;
        }

        $amount = $this->round($amount);

        $sale = $this->invoice->getSale();
        $date = $sale->getCreatedAt();
        $taxRule = $this->taxResolver->resolveSaleTaxRule($sale);

        $account = $this->getShipmentAccountNumber($taxRule, $this->invoice->getNumber());

        if ($this->invoice->getType() === InvoiceTypes::TYPE_CREDIT) {
            $this->writer->credit($account, (string)$amount, $date);
            $this->balance -= $amount;
        } else {
            $this->writer->debit($account, (string)$amount, $date);
            $this->balance += $amount;
        }
    }

    /**
     * Writes the invoice's taxes lines.
     */
    protected function writeInvoiceTaxesLine()
    {
        $sale = $this->invoice->getSale();
        $date = $sale->getCreatedAt();

        $credit = $this->invoice->getType() === InvoiceTypes::TYPE_CREDIT;

        foreach ($this->invoice->getTaxesDetails() as $detail) {
            $amount = $this->round($detail['amount']);

            if (0 === $this->compare($amount, 0)) {
                continue; // next tax details
            }

            $account = $this->getTaxAccountNumber($detail['rate'], $this->invoice->getNumber());

            if ($credit) {
                $this->writer->credit($account, (string)$amount, $date);
                $this->balance -= $amount;
            } else {
                $this->writer->debit($account, (string)$amount, $date);
                $this->balance += $amount;
            }
        }
    }

    /**
     * Rounds the amount.
     *
     * @param $amount
     *
     * @return float
     */
    protected function round(float $amount)
    {
        // TODO currency conversion ?
        return Money::round($amount, $this->currency);
    }

    /**
     * Compare the amounts.
     *
     * @param $a
     * @param $b
     *
     * @return float
     */
    protected function compare(float $a, float $b)
    {
        // TODO currency conversion ?
        return Money::compare($a, $b, $this->currency);
    }

    /**
     * Return the goods account number for the given tax rule and tax rate.
     *
     * @param TaxRuleInterface $rule
     * @param float            $rate
     * @param string           $origin
     *
     * @return string
     */
    protected function getGoodAccountNumber(TaxRuleInterface $rule, float $rate, string $origin)
    {
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_GOOD) {
                continue;
            }

            if ($account->getTaxRule() !== $rule) {
                continue;
            }

            if (is_null($account->getTax())) {
                if ($rate == 0) {
                    return $account->getNumber();
                }

                continue;
            }

            if (0 === bccomp($account->getTax()->getRate(), $rate, 5)) {
                return $account->getNumber();
            }
        }

        throw new LogicException(sprintf(
            "No goods account number configured for tax rule '%s' and tax rate %s (%s)",
            $rule->getName(),
            $rate,
            $origin
        ));
    }

    /**
     * Returns the shipment account number for the given tax rule.
     *
     * @param TaxRuleInterface $rule
     * @param string           $origin
     *
     * @return string
     */
    protected function getShipmentAccountNumber(TaxRuleInterface $rule, string $origin)
    {
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_SHIPPING) {
                continue;
            }

            if ($account->getTaxRule() !== $rule) {
                continue;
            }

            return $account->getNumber();
        }

        throw new LogicException(sprintf(
            "No shipment account number configured for tax rule '%s' (%s)",
            $rule->getName(),
            $origin
        ));
    }

    /**
     * Returns the tax account number for the given tax rate.
     *
     * @param float  $rate
     * @param string $origin
     *
     * @return string
     */
    protected function getTaxAccountNumber(float $rate, string $origin)
    {
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_TAX) {
                continue;
            }

            if (0 !== bccomp($account->getTax()->getRate(), $rate, 5)) {
                continue;
            }

            return $account->getNumber();
        }

        throw new LogicException(sprintf(
            "No tax account number configured for tax rate '%s' (%s)",
            $rate,
            $origin
        ));
    }

    /**
     * Returns the payment account number for the given payment method.
     *
     * @param PaymentMethodInterface $method
     * @param string                 $origin
     *
     * @return string
     */
    protected function getPaymentAccountNumber(PaymentMethodInterface $method, string $origin)
    {
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_PAYMENT) {
                continue;
            }

            if ($account->getPaymentMethod() !== $method) {
                continue;
            }

            return $account->getNumber();
        }

        throw new LogicException(sprintf(
            "No payment account number configured for payment method '%s' (%s)",
            $method->getName(),
            $origin
        ));
    }

    /**
     * Returns the unpaid account number for the given customer group.
     *
     * @param CustomerGroupInterface $group
     * @param string                 $origin
     *
     * @return string
     */
    protected function getUnpaidAccountNumber(CustomerGroupInterface $group, string $origin)
    {
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_UNPAID) {
                continue;
            }

            foreach ($account->getCustomerGroups() as $g) {
                if ($g->getId() === $group->getId()) {
                    return $account->getNumber();
                }
            }
        }

        // Fallback to 'all' (empty) customer groups
        foreach ($this->accounts as $account) {
            if ($account->getType() !== AccountingTypes::TYPE_UNPAID) {
                continue;
            }

            if (0 < $account->getCustomerGroups()->count()) {
                continue;
            }

            return $account->getNumber();
        }

        throw new LogicException(sprintf(
            "No unpaid account number configured for customer group '%s' (%s)",
            $group->getName(),
            $origin
        ));
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Gateway;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;

/**
 * Class GatewayActions
 * @package Ekyna\Component\Commerce\Shipment\Gateway
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class GatewayActions
{
    const SHIP              = 'ship';
    const CANCEL            = 'cancel';
    const COMPLETE          = 'complete';
    const TRACK             = 'track';
    const PROVE             = 'prove';
    const PRINT_LABEL       = 'print_label';
    const LIST_RELAY_POINTS = 'list_relay_points';
    const GET_RELAY_POINT   = 'get_relay_point';

    /**
     * Returns the api actions.
     *
     * @return array
     */
    public static function getApiActions()
    {
        return [
            static::LIST_RELAY_POINTS,
            static::GET_RELAY_POINT,
        ];
    }

    /**
     * Returns the shipment actions.
     *
     * @return array
     */
    public static function getShipmentActions()
    {
        return [
            static::SHIP,
            static::CANCEL,
            static::COMPLETE,
            static::PRINT_LABEL,
        ];
    }

    /**
     * Returns all the actions.
     *
     * @return array
     */
    public static function getActions()
    {
        return [
            static::SHIP,
            static::CANCEL,
            static::COMPLETE,
            static::TRACK,
            static::PROVE,
            static::PRINT_LABEL,
            static::LIST_RELAY_POINTS,
            static::GET_RELAY_POINT,
        ];
    }

    /**
     * Returns whether the given action is valid.
     *
     * @param string $action
     * @param bool   $throw
     *
     * @return bool
     */
    public static function isValid($action, $throw = false)
    {
        if (in_array($action, static::getActions(), true)) {
            return true;
        }

        if ($throw) {
            throw new InvalidArgumentException("Unknown gateway action '$action'.");
        }

        return false;
    }

    /**
     * Disabled constructor.
     */
    private function __construct()
    {

    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Resolver;

use Ekyna\Component\Commerce\Common\Model\AddressInterface;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentInterface;
use Ekyna\Component\Commerce\Shipment\Transformer\ShipmentAddressTransformer;

/**
 * Class ShipmentAddressResolver
 * @package Ekyna\Component\Commerce\Shipment\Resolver
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class ShipmentAddressResolver implements ShipmentAddressResolverInterface
{
    /**
     * @var ShipmentAddressTransformer
     */
    protected $transformer;

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

    /**
     * @inheritDoc
     */
    public function getCountryRepository()
    {
        return $this->transformer->getCountryRepository();
    }

    /**
     * @inheritDoc
     */
    public function resolveSenderAddress(ShipmentInterface $shipment, bool $ignoreRelay = false)
    {
        if (!$ignoreRelay && $shipment->isReturn() && null !== $address = $shipment->getRelayPoint()) {
            return $address;
        }

        if (!empty($address = $shipment->getSenderAddress())) {
            return $this->transformer->transform($address);
        }

        if ($shipment->isReturn()) {
            return $this->getSaleDeliveryAddress($shipment);
        }

        return $this->getCompanyAddress();
    }

    /**
     * @inheritDoc
     */
    public function resolveReceiverAddress(ShipmentInterface $shipment, bool $ignoreRelay = false)
    {
        if (!$ignoreRelay && !$shipment->isReturn() && null !== $address = $shipment->getRelayPoint()) {
            return $address;
        }

        if (!empty($address = $shipment->getReceiverAddress())) {
            return $this->transformer->transform($address);
        }

        if ($shipment->isReturn()) {
            return $this->getCompanyAddress();
        }

        return $this->getSaleDeliveryAddress($shipment);
    }

    /**
     * Returns the company address.
     *
     * @return AddressInterface $address
     */
    abstract protected function getCompanyAddress();

    /**
     * Returns the delivery address of the shipment's sale.
     *
     * @param ShipmentInterface $shipment
     *
     * @return AddressInterface
     * @throws LogicException
     */
    private function getSaleDeliveryAddress(ShipmentInterface $shipment)
    {
        if (null === $sale = $shipment->getSale()) {
            throw new LogicException("Shipment's sale must be set at this point.");
        }

        return $sale->isSameAddress() ? $sale->getInvoiceAddress() : $sale->getDeliveryAddress();
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Prioritizer;

use Doctrine\ORM\EntityManagerInterface;
use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Exception\StockLogicException;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentStates;
use Ekyna\Component\Commerce\Stock\Assigner\StockUnitAssignerInterface;
use Ekyna\Component\Commerce\Stock\Logger\StockLoggerInterface;
use Ekyna\Component\Commerce\Stock\Model as Stock;
use Ekyna\Component\Commerce\Stock\Resolver\StockUnitResolverInterface;

/**
 * Class StockPrioritizer
 * @package Ekyna\Component\Commerce\Stock\Prioritizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class StockPrioritizer implements StockPrioritizerInterface
{
    /**
     * @var StockUnitResolverInterface
     */
    protected $unitResolver;

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

    /**
     * @var SaleFactoryInterface
     */
    protected $saleFactory;

    /**
     * @var EntityManagerInterface
     */
    protected $manager;

    /**
     * @var StockLoggerInterface
     */
    protected $logger;

    /**
     * Constructor.
     *
     * @param StockUnitResolverInterface $unitResolver
     * @param StockUnitAssignerInterface $unitAssigner
     * @param SaleFactoryInterface       $saleFactory
     * @param EntityManagerInterface     $manager
     * @param StockLoggerInterface $logger
     */
    public function __construct(
        StockUnitResolverInterface $unitResolver,
        StockUnitAssignerInterface $unitAssigner,
        SaleFactoryInterface $saleFactory,
        EntityManagerInterface $manager,
        StockLoggerInterface $logger
    ) {
        $this->unitResolver = $unitResolver;
        $this->unitAssigner = $unitAssigner;
        $this->saleFactory = $saleFactory;
        $this->manager = $manager;
        $this->logger = $logger;
    }

    /**
     * @inheritdoc
     */
    public function canPrioritizeSale(Common\SaleInterface $sale)
    {
        if ($sale->getState() === ShipmentStates::STATE_COMPLETED) {
            return false;
        }

        foreach ($sale->getItems() as $item) {
            if ($this->canPrioritizeSaleItem($item)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function canPrioritizeSaleItem(Common\SaleItemInterface $item)
    {
        foreach ($item->getChildren() as $child) {
            if ($this->canPrioritizeSaleItem($child)) {
                return true;
            }
        }

        if (!$item instanceof Stock\StockAssignmentsInterface) {
            return false;
        }

        $assignments = $item->getStockAssignments();

        if (0 === $assignments->count()) {
            return $this->unitAssigner->supportsAssignment($item);
        }

        foreach ($assignments as $assignment) {
            if (!$assignment->isFullyShipped() && !$assignment->isFullyShippable()) {
                return true;
            }
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function prioritizeSale(Common\SaleInterface $sale)
    {
        $changed = false;

        foreach ($sale->getItems() as $item) {
            $changed |= $this->prioritizeSaleItem($item);
        }

        return $changed;
    }

    /**
     * @inheritdoc
     */
    public function prioritizeSaleItem(Common\SaleItemInterface $item)
    {
        $changed = false;

        foreach ($item->getChildren() as $child) {
            $changed |= $this->prioritizeSaleItem($child);
        }

        if (!$item instanceof Stock\StockAssignmentsInterface) {
            return $changed;
        }

        $assignments = $item->getStockAssignments();

        if (0 === $assignments->count()) {
            if ($this->unitAssigner->supportsAssignment($item)) {
                $this->unitAssigner->assignSaleItem($item);

                $changed = true;
            }

            return $changed;
        }

        foreach ($item->getStockAssignments() as $assignment) {
            $changed |= $this->prioritizeAssignment($assignment);
        }

        return $changed;
    }

    /**
     * Prioritize the stock assignment.
     *
     * @param Stock\StockAssignmentInterface $assignment
     *
     * @return bool Whether the assignment has been prioritized.
     */
    protected function prioritizeAssignment(Stock\StockAssignmentInterface $assignment)
    {
        if ($assignment->isFullyShipped() || $assignment->isFullyShippable()) {
            return false;
        }

        // Get the non shippable quantity
        if (0 >= $quantity = $assignment->getSoldQuantity() - $assignment->getShippableQuantity()) {
            return false;
        }

        // Options are:
        // - Splitting non shippable quantity to other stock unit(s)
        // - Moving the whole assignment to other stock unit(s) (TODO)
        // - Moving other assignment(s) to other stock unit(s) (TODO)

        $changed = false;

        $helper = new PrioritizeHelper($this->unitResolver);

        $sourceUnit = $assignment->getStockUnit();

        $candidates = $helper->getUnitCandidates($assignment, $quantity);

        foreach ($candidates as $candidate) {
            $targetUnit = $candidate->unit;

            // If not enough reservable quantity
            if ((0 < $quantity - $targetUnit->getReservableQuantity()) && ($combination = $candidate->combination)) {
                // Use combination to release quantity
                foreach ($combination->map as $id => $qty) {
                    if (null === $a = $candidate->getAssignmentById($id)) {
                        throw new StockLogicException("Assignment not found.");
                    }

                    // Move assignment to the source unit
                    $this->moveAssignment($a, $sourceUnit, min($qty, $quantity));
                }
            }

            // Move assignment to the target unit.
            $delta = min($quantity, $targetUnit->getReservableQuantity());
            $quantity -= $this->moveAssignment($assignment, $targetUnit, $delta);

            // TODO Validate units ?

            $changed = true;

            if (0 >= $quantity) {
                break;
            }
        }

        return $changed;
    }

    /**
     * Move the given assignment to the given unit for the given sold quantity.
     *
     * @param Stock\StockAssignmentInterface $assignment
     * @param Stock\StockUnitInterface       $targetUnit
     * @param float                          $quantity
     *
     * @return float The quantity moved
     */
    protected function moveAssignment(
        Stock\StockAssignmentInterface $assignment,
        Stock\StockUnitInterface $targetUnit,
        $quantity
    ) {
        /**
         * TODO Refactor with:
         * @see \Ekyna\Component\Commerce\Stock\Dispatcher\StockAssignmentDispatcher::moveAssignments()
         */

        // Don't move shipped quantity
        $quantity = min($quantity, $assignment->getSoldQuantity() - $assignment->getShippedQuantity());
        if (0 >= $quantity) { // TODO Packaging format
            return 0;
        }

        $sourceUnit = $assignment->getStockUnit();
        $saleItem = $assignment->getSaleItem();

        // Debit source unit's sold quantity
        $this->logger->unitSold($sourceUnit, -$quantity);
        $sourceUnit->setSoldQuantity($sourceUnit->getSoldQuantity() - $quantity);
        $this->manager->persist($sourceUnit);

        // Credit target unit
        $this->logger->unitSold($targetUnit, $quantity);
        $targetUnit->setSoldQuantity($targetUnit->getSoldQuantity() + $quantity);
        $this->manager->persist($targetUnit);

        // Merge assignment lookup
        $merge = null;
        foreach ($targetUnit->getStockAssignments() as $m) {
            if ($m->getSaleItem() === $saleItem) {
                $merge = $m;
                break;
            }
        }

        if ($quantity == $assignment->getSoldQuantity()) {
            if (null !== $merge) {
                // Credit quantity to mergeable assignment
                $this->logger->assignmentSold($merge, $quantity);
                $merge->setSoldQuantity($merge->getSoldQuantity() + $quantity);
                $this->manager->persist($merge);

                // Debit quantity from source assignment
                $this->logger->assignmentSold($assignment, 0, false); // TODO log removal ?
                $assignment
                    ->setSoldQuantity(0)
                    ->setSaleItem(null)
                    ->setStockUnit(null);
                $this->manager->remove($assignment);
            } else {
                // Move source assignment to target unit
                $this->logger->assignmentUnit($assignment, $targetUnit);
                $assignment->setStockUnit($targetUnit);
                $this->manager->persist($assignment);
            }
        } else {
            // Debit quantity from source assignment
            $this->logger->assignmentSold($assignment, -$quantity);
            $assignment->setSoldQuantity($assignment->getSoldQuantity() - $quantity);
            $this->manager->persist($assignment);

            if (null !== $merge) {
                // Credit quantity to mergeable assignment
                $this->logger->assignmentSold($merge, $quantity);
                $merge->setSoldQuantity($merge->getSoldQuantity() + $quantity);
                $this->manager->persist($merge);
            } else {
                // Credit quantity to new assignment
                $create = $this->saleFactory->createStockAssignmentForItem($saleItem);
                $this->logger->assignmentSold($create, $quantity, false);
                $create
                    ->setSoldQuantity($quantity)
                    ->setSaleItem($saleItem)
                    ->setStockUnit($targetUnit);

                $this->manager->persist($create);
            }
        }

        return $quantity;
    }
}


<?php

namespace Ekyna\Component\Commerce\Supplier\EventListener;

use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderItemInterface;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderStates;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class SupplierOrderItemListener
 * @package Ekyna\Component\Commerce\Supplier\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierOrderItemListener extends AbstractListener
{
    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $item = $this->getSupplierOrderItemFromEvent($event);

        $changed = $this->synchronizeWithProduct($item);

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

            $this->scheduleSupplierOrderContentChangeEvent($item->getOrder());
        }

        // If supplier order state is 'ordered', 'partial' or 'completed'
        if (SupplierOrderStates::isStockableState($item->getOrder()->getState())) {
            // Associated stock unit (if not exists) must be created (absolute ordered quantity).
            $this->stockUnitLinker->linkItem($item);
        } else { // Supplier order state is 'new' or 'canceled'
            // Associated stock unit (if exists) must be deleted.
            $this->stockUnitLinker->unlinkItem($item);
        }
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     * @throws IllegalOperationException
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $item = $this->getSupplierOrderItemFromEvent($event);

        // Disallow product change
        if ($this->persistenceHelper->isChanged($item, 'product')) {
            $productCs = $this->persistenceHelper->getChangeSet($item, 'product');
            if ($productCs[0] != $productCs[1]) {
                // TODO message as translation id
                throw new IllegalOperationException("Changing supplier order item product is not supported yet.");
            }
        }

        $changed = $this->synchronizeWithProduct($item);
        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($item, false);
        }

        // TODO These tests are made in the supplier order listener and should not be done twice...
        $order = $item->getOrder();
        if ($this->persistenceHelper->isChanged($order, 'state')) {
            $stateCs = $this->persistenceHelper->getChangeSet($order, 'state');

            // If order just did a stockable state transition
            if (
                SupplierOrderStates::hasChangedFromStockable($stateCs) ||
                SupplierOrderStates::hasChangedToStockable($stateCs)
            ) {
                // Abort (handled by the supplier order listener)
                return;
            }
        }

        if (SupplierOrderStates::isStockableState($order->getState())) {
            // Updates the ordered quantity and price
            if ($this->stockUnitLinker->applyItem($item)) {
                // Dispatch supplier order content change event
                $this->scheduleSupplierOrderContentChangeEvent($item->getOrder());
            }
        }
    }

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

        $this->assertDeletable($item);

        // TODO If not made by the supplierOrderListener ?
        //$this->deleteSupplierOrderItemStockUnit($item);
        $this->stockUnitLinker->unlinkItem($item);

        // Supplier order has been set to null by the removeItem method.
        // Retrieve it from the change set.
        if (null === $order = $item->getOrder()) {
            $changeSet = $this->persistenceHelper->getChangeSet($item);
            if (array_key_exists('order', $changeSet)) {
                $order = $changeSet['order'][0];
            }
        }
        if (null === $order) {
            throw new RuntimeException("Failed to retrieve supplier order.");
        }

        // Clear association
        $item->setOrder(null);
        /* @see SupplierDeliveryListener::onDelete */
        //$order->getItems()->removeElement($item);

        // Trigger the supplier order update
        if (!$this->persistenceHelper->isScheduledForRemove($order)) {
            $this->scheduleSupplierOrderContentChangeEvent($order);
        }
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $item = $this->getSupplierOrderItemFromEvent($event);

        $this->assertDeletable($item);

        // Initializes the supplier order's items collection before item removal.
        $item->getOrder()->getItems();
    }

    /**
     * Synchronises with the supplier product.
     *
     * @param SupplierOrderItemInterface $item
     *
     * @return bool Whether or not the item has been changed.
     *
     * @throws LogicException If breaking synchronization between supplier order item and supplier product.
     * @throws InvalidArgumentException If supplier product subject data is not set.
     */
    protected function synchronizeWithProduct(SupplierOrderItemInterface $item)
    {
        $changed = false;

        // TODO What about stock management if subject change ???
        if (null !== $product = $item->getProduct()) {
            // TODO Create an utility class to do this
            $productSID = $product->getSubjectIdentity();
            if ($productSID->hasIdentity()) {
                $itemSID = $item->getSubjectIdentity();

                if ($itemSID->hasIdentity()) {
                    if (!$itemSID->equals($productSID)) {
                        throw new LogicException(
                            'Breaking synchronization between supplier order item and supplier product is not supported.'
                        );
                    }
                    $changed = false;
                } else {
                    $itemSID->copy($productSID);
                    $changed = true;
                }
            } else {
                throw new InvalidArgumentException(
                    'Supplier product subject identity is not set.'
                );
            }

            if (0 == strlen($item->getDesignation())) {
                $item->setDesignation($product->getDesignation());
            }
            if (0 == strlen($item->getReference())) {
                $item->setReference($product->getReference());
            }
            if (0 == $item->getNetPrice()) {
                $item->setNetPrice($product->getNetPrice());
            }
        } elseif ($item->hasSubjectIdentity()) {
            throw new LogicException(
                'Breaking synchronization between supplier order item and supplier product is not supported.'
            );
        }

        return $changed;
    }

    /**
     * Returns the supplier order item from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return SupplierOrderItemInterface
     * @throws InvalidArgumentException
     */
    protected function getSupplierOrderItemFromEvent(ResourceEventInterface $event)
    {
        $item = $event->getResource();

        if (!$item instanceof SupplierOrderItemInterface) {
            throw new InvalidArgumentException("Expected instance of SupplierOrderItemInterface.");
        }

        return $item;
    }
}


<?php
namespace AuthActions\Lib;

class UserRights {

/**
 * Holds the rights config.
 *
 * Format:
 * 		'userCanDoStuff' => array(
 * 			role1, role2
 * 		),
 * 		'userCanDoOtherStuff' => array(
 * 			role3
 * 		)
 *
 * @var string
 */
	protected $_rightsConfig = array();

/**
 * Constructor
 *
 * @param array $rightsConfig The rights configuration
 */
	public function __construct(array $rightsConfig) {
		$this->_rightsConfig = $rightsConfig;
	}

/**
 * Checks if the given user has a right
 *
 * @param array $user user to check
 * @param string $right right name
 * @return bool
 */
	public function userHasRight(array $user, $right) {
		$hasRight = false;
		if (isset($user['role']) && !empty($right) && isset($this->_rightsConfig[$right])) {
			if (in_array($user['role'], $this->_rightsConfig[$right])) {
				$hasRight = true;
			}
		}
		return $hasRight;
	}
}


<?php

namespace Ekyna\Component\Commerce\Invoice\Calculator;

use Ekyna\Component\Commerce\Common\Model\SaleAdjustmentInterface;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Common\Model\SaleItemInterface;
use Ekyna\Component\Commerce\Document\Model\DocumentLineTypes;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Invoice\Model as Invoice;
use Ekyna\Component\Commerce\Shipment\Calculator\ShipmentCalculatorInterface;

/**
 * Class QuantityCalculator
 * @package Ekyna\Component\Commerce\Invoice\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class InvoiceCalculator implements InvoiceCalculatorInterface
{
    /**
     * @var ShipmentCalculatorInterface
     */
    private $shipmentCalculator;

    /**
     * Sets the shipment calculator.
     *
     * @param ShipmentCalculatorInterface $calculator
     */
    public function setShipmentCalculator($calculator)
    {
        $this->shipmentCalculator = $calculator;
    }

    /**
     * @inheritdoc
     */
    public function isInvoiced($itemOrAdjustment)
    {
        if ($itemOrAdjustment instanceof SaleItemInterface) {
            // If compound with only public children
            if ($itemOrAdjustment->isCompound() && !$itemOrAdjustment->hasPrivateChildren()) {
                // Invoiced if any of it's children is
                foreach ($itemOrAdjustment->getChildren() as $child) {
                    if ($this->isInvoiced($child)) {
                        return true;
                    }
                }

                return false;
            }

            $sale = $itemOrAdjustment->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return false;
            }

            foreach ($sale->getInvoices() as $invoice) {
                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_GOOD) as $line) {
                    if ($line->getSaleItem() === $itemOrAdjustment) {
                        return true;
                    }
                }
            }

            return false;
        }

        if ($itemOrAdjustment instanceof SaleAdjustmentInterface) {
            $sale = $itemOrAdjustment->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return false;
            }

            foreach ($sale->getInvoices() as $invoice) {
                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_DISCOUNT) as $line) {
                    if ($line->getSaleAdjustment() === $itemOrAdjustment) {
                        return true;
                    }
                }
            }

            return false;
        }

        throw new InvalidArgumentException(
            "Expected instance of " . SaleItemInterface::class . " or " . SaleAdjustmentInterface::class
        );
    }

    /**
     * @inheritdoc
     */
    public function calculateInvoiceableQuantity($subject, Invoice\InvoiceInterface $ignore = null)
    {
        // Good line case
        if ($subject instanceof SaleItemInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            // Quantity = Total - Invoiced (ignoring current invoice)
            $quantity = $subject->getTotalQuantity()
                - $this->calculateInvoicedQuantity($subject, $ignore);

            return max(0, $quantity);
        }

        // Discount line case
        if ($subject instanceof SaleAdjustmentInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            // Discounts must be dispatched into all invoices
            return 1;
        }

        // Shipment line case
        if ($subject instanceof SaleInterface) {
            if (!$subject instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $invoiced = $this->calculateInvoicedQuantity($subject, $ignore);

            // Shipment must be invoiced once
            return min(1, max(0, 1 - $invoiced));
        }

        throw new InvalidArgumentException(sprintf(
            "Unexpected instance of %s, %s or %s",
            SaleInterface::class,
            SaleItemInterface::class,
            SaleAdjustmentInterface::class
        ));
    }

    /**
     * @inheritdoc
     */
    public function calculateCreditableQuantity($subject, Invoice\InvoiceInterface $ignore = null)
    {
        // Good line case
        if ($subject instanceof SaleItemInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            // Quantity = Invoiced - Shipped + Returned - Credited (ignoring current credit)
            $quantity = $this->calculateInvoicedQuantity($subject)
                - $this->shipmentCalculator->calculateShippedQuantity($subject)
                + $this->shipmentCalculator->calculateReturnedQuantity($subject)
                - $this->calculateCreditedQuantity($subject, $ignore);

            return max(0, $quantity);
        }

        // Discount line case
        if ($subject instanceof SaleAdjustmentInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            // Discounts must be dispatched into all invoices
            return 1;
        }

        // Shipment line case
        if ($subject instanceof SaleInterface) {
            if (!$subject instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            // Shipment can be credited once
            return max(1, $this->calculateInvoicedQuantity($subject));
        }

        throw new InvalidArgumentException(sprintf(
            "Unexpected instance of %s, %s or %s",
            SaleInterface::class,
            SaleItemInterface::class,
            SaleAdjustmentInterface::class
        ));
    }

    /**
     * @inheritdoc
     */
    public function calculateInvoicedQuantity($subject, Invoice\InvoiceInterface $ignore = null)
    {
        // Good line case
        if ($subject instanceof SaleItemInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($sale->getInvoices() as $invoice) {
                if (Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_GOOD) as $line) {
                    if ($line->getSaleItem() === $subject) {
                        $quantity += $line->getQuantity();
                    }
                }
            }

            return $quantity;
        }

        // Discount line case
        if ($subject instanceof SaleAdjustmentInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($sale->getInvoices() as $invoice) {
                if (Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_DISCOUNT) as $line) {
                    if ($line->getSaleAdjustment() === $subject) {
                        $quantity += $line->getQuantity();
                    }
                }
            }

            return $quantity;
        }

        // Shipment line case
        if ($subject instanceof SaleInterface) {
            if (!$subject instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($subject->getInvoices() as $invoice) {
                if (Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_SHIPMENT) as $line) {
                    $quantity += $line->getQuantity();
                }
            }

            return $quantity;
        }

        throw new InvalidArgumentException(sprintf(
            "Unexpected instance of %s, %s or %s",
            SaleInterface::class,
            SaleItemInterface::class,
            SaleAdjustmentInterface::class
        ));
    }

    /**
     * @inheritdoc
     */
    public function calculateCreditedQuantity($subject, Invoice\InvoiceInterface $ignore = null)
    {
        // Good line case
        if ($subject instanceof SaleItemInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($sale->getInvoices() as $invoice) {
                if (!Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_GOOD) as $line) {
                    if ($line->getSaleItem() === $subject) {
                        $quantity += $line->getQuantity();
                    }
                }
            }

            return $quantity;
        }

        // Discount line case
        if ($subject instanceof SaleAdjustmentInterface) {
            $sale = $subject->getSale();
            if (!$sale instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($sale->getInvoices() as $invoice) {
                if (!Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_DISCOUNT) as $line) {
                    if ($line->getSaleAdjustment() === $subject) {
                        $quantity += $line->getQuantity();
                    }
                }
            }

            return $quantity;
        }

        // Shipment line case
        if ($subject instanceof SaleInterface) {
            if (!$subject instanceof Invoice\InvoiceSubjectInterface) {
                return 0;
            }

            $quantity = 0;
            foreach ($subject->getInvoices() as $invoice) {
                if (!Invoice\InvoiceTypes::isCredit($invoice) || $invoice === $ignore) {
                    continue;
                }

                foreach ($invoice->getLinesByType(DocumentLineTypes::TYPE_SHIPMENT) as $line) {
                    $quantity += $line->getQuantity();
                }
            }

            return $quantity;
        }

        throw new InvalidArgumentException(sprintf(
            "Unexpected instance of %s, %s or %s",
            SaleInterface::class,
            SaleItemInterface::class,
            SaleAdjustmentInterface::class
        ));
    }

    /**
     * @inheritdoc
     */
    public function calculateInvoiceTotal(Invoice\InvoiceSubjectInterface $subject)
    {
        $total = .0;

        foreach ($subject->getInvoices() as $invoice) {
            if (Invoice\InvoiceTypes::isInvoice($invoice)) {
                $total += $invoice->getGrandTotal();
            }
        }

        return $total;
    }

    /**
     * @inheritdoc
     */
    public function calculateCreditTotal(Invoice\InvoiceSubjectInterface $subject)
    {
        $total = .0;

        foreach ($subject->getInvoices() as $invoice) {
            if (Invoice\InvoiceTypes::isCredit($invoice)) {
                $total += $invoice->getGrandTotal();
            }
        }

        return $total;
    }

    /**
     * @inheritdoc
     */
    public function buildInvoiceQuantityMap(Invoice\InvoiceSubjectInterface $subject)
    {
        $quantities = [];

        if ($subject instanceof SaleInterface) {
            foreach ($subject->getItems() as $item) {
                $this->buildSaleItemQuantities($item, $quantities);
            }
        }

        return $quantities;
    }

    /**
     * Builds the sale item quantities recursively.
     *
     * @param SaleItemInterface $item
     * @param array             $quantities
     */
    private function buildSaleItemQuantities(SaleItemInterface $item, array &$quantities)
    {
        // Skip compound with only public children
        if (!($item->isCompound() && !$item->hasPrivateChildren())) {
            $quantities[$item->getId()] = [
                'total'    => $item->getTotalQuantity(),
                'invoiced' => $this->calculateInvoicedQuantity($item),
                'credited' => $this->calculateCreditedQuantity($item),
            ];
        }

        if ($item->hasChildren()) {
            foreach ($item->getChildren() as $child) {
                $this->buildSaleItemQuantities($child, $quantities);
            }
        }
    }
}


<?php

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

use Doctrine\DBAL\Types\Type;
use Ekyna\Component\Commerce\Supplier\Model;
use Ekyna\Component\Commerce\Supplier\Repository\SupplierOrderRepositoryInterface;
use Ekyna\Component\Resource\Doctrine\ORM\ResourceRepository;

/**
 * Class SupplierOrderRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierOrderRepository extends ResourceRepository implements SupplierOrderRepositoryInterface
{
    /**
     * @var \Doctrine\ORM\Query
     */
    private $findNewBySupplierQuery;

    /**
     * @inheritDoc
     */
    public function findNewBySupplier(Model\SupplierInterface $supplier)
    {
        return $this
            ->getFindNewBySupplierQuery()
            ->setParameter('supplier', $supplier)
            ->getResult();
    }

    /**
     * @inheritDoc
     */
    public function findSuppliersExpiredDue()
    {
        return $this->findExpiredDue('payment');
    }

    /**
     * @inheritDoc
     */
    public function findSuppliersFallDue()
    {
        return $this->findFallDue('payment');
    }

    /**
     * @inheritDoc
     */
    public function findForwardersExpiredDue()
    {
        return $this->findExpiredDue('forwarder');
    }

    /**
     * @inheritDoc
     */
    public function findForwardersFallDue()
    {
        return $this->findFallDue('forwarder');
    }

    /**
     * @inheritDoc
     */
    public function getSuppliersExpiredDue()
    {
        return $this->getExpiredDue('payment');
    }

    /**
     * @inheritDoc
     */
    public function getSuppliersFallDue()
    {
        return $this->getFallDue('payment');
    }

    /**
     * @inheritDoc
     */
    public function getForwardersExpiredDue()
    {
        return $this->getExpiredDue('forwarder');
    }

    /**
     * @inheritDoc
     */
    public function getForwardersFallDue()
    {
        return $this->getFallDue('forwarder');
    }

    /**
     * Returns the "find new by supplier" query.
     *
     * @return \Doctrine\ORM\Query
     */
    private function getFindNewBySupplierQuery()
    {
        if (null !== $this->findNewBySupplierQuery) {
            return $this->findNewBySupplierQuery;
        }

        $qb = $this->createQueryBuilder();
        $as = $this->getAlias();

        return $this->findNewBySupplierQuery = $qb
            ->andWhere($qb->expr()->eq($as . '.supplier', ':supplier'))
            ->andWhere($qb->expr()->eq($as . '.state', ':state'))
            ->getQuery()
            ->setParameter('state', Model\SupplierOrderStates::STATE_NEW);
    }

    /**
     * Returns the expired due orders.
     *
     * @param string $prefix
     *
     * @return Model\SupplierOrderInterface[]
     */
    private function findExpiredDue($prefix)
    {
        return $this
            ->getExpiredDueQueryBuilder($prefix)
            ->getQuery()
            ->getResult();
    }

    /**
     * Returns the fall due orders.
     *
     * @param string $prefix
     *
     * @return Model\SupplierOrderInterface[]
     */
    private function findFallDue($prefix)
    {
        return $this
            ->getFallDueQueryBuilder($prefix)
            ->getQuery()
            ->getResult();
    }

    /**
     * Returns the expired due total.
     *
     * @param string $prefix
     *
     * @return float
     */
    private function getExpiredDue($prefix)
    {
        return $this
            ->getExpiredDueQueryBuilder($prefix)
            ->select('SUM(' . $this->getAlias() . '.' . $prefix . 'Total)')
            ->getQuery()
            ->getSingleScalarResult();
    }

    /**
     * Returns the fall due total.
     *
     * @param string $prefix
     *
     * @return float
     */
    private function getFallDue($prefix)
    {
        return $this
            ->getFallDueQueryBuilder($prefix)
            ->select('SUM(' . $this->getAlias() . '.' . $prefix . 'Total)')
            ->getQuery()
            ->getSingleScalarResult();
    }

    /**
     * Returns the expired due total.
     *
     * @param string $prefix
     *
     * @return \Doctrine\ORM\QueryBuilder
     */
    private function getExpiredDueQueryBuilder($prefix)
    {
        $qb = $this->createQueryBuilder();
        $as = $this->getAlias();
        $ex = $qb->expr();

        return $qb
            ->andWhere($ex->gt($as . '.' . $prefix . 'Total', 0))
            ->andWhere($ex->isNull($as . '.' . $prefix . 'Date'))
            ->andWhere($ex->andX(
                $ex->isNotNull($as . '.' . $prefix . 'DueDate'),
                $ex->lt($as . '.' . $prefix . 'DueDate', ':today')
            ))
            ->setParameter('today', (new \DateTime())->setTime(0, 0, 0), Type::DATETIME);
    }

    /**
     * Returns the fall due total.
     *
     * @param string $prefix
     *
     * @return \Doctrine\ORM\QueryBuilder
     */
    private function getFallDueQueryBuilder($prefix)
    {
        $qb = $this->createQueryBuilder();
        $as = $this->getAlias();
        $ex = $qb->expr();

        return $qb
            ->andWhere($ex->gt($as . '.' . $prefix . 'Total', 0))
            ->andWhere($ex->isNull($as . '.' . $prefix . 'Date'))
            ->andWhere($ex->orX(
                $ex->isNull($as . '.' . $prefix . 'DueDate'),
                $ex->gte($as . '.' . $prefix . 'DueDate', ':today')
            ))
            ->setParameter('today', (new \DateTime())->setTime(0, 0, 0), Type::DATETIME);
    }

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