<?php
declare(strict_types=1);
namespace WbfkExtensions\Service;
use DateInterval;
use DateTime;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Order\OrderStates;
use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\OrFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\SystemConfig\Event\SystemConfigChangedEvent;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Messenger\Handler\MessageSubscriberInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use WbfkExtensions\MessageQueue\Message\RecalculateTopSeller;
class TopSellerService implements EventSubscriberInterface, MessageSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
SystemConfigChangedEvent::class => 'onSystemConfigChanged',
ProductListingCriteriaEvent::class => 'onProductListingCriteriaEvent',
];
}
public static function getHandledMessages(): iterable
{
yield RecalculateTopSeller::class => [
'method' => 'recalculateProductSales',
];
}
public function __construct(
private readonly SystemConfigService $systemConfigService,
private readonly EntityRepository $salesChannelRepository,
private readonly Connection $connection,
private readonly LockFactory $lockFactory,
private readonly MessageBusInterface $messageBus,
) {
}
/**
* In the plugin-configuration is specified what durations we are interested in.
* Recalculate the Sales-Numbers if the configuration changed.
*/
public function onSystemConfigChanged(SystemConfigChangedEvent $event): void
{
if ($event->getKey() === 'WbfkExtensions.config.topseller') {
$msg = new RecalculateTopSeller($event->getSalesChannelId() ? [$event->getSalesChannelId()] : []);
$lock = $this->lockFactory->createLock($msg->getLockName(), 300, false);
if ($lock->acquire()) {
$this->messageBus->dispatch($msg);
}
}
}
/**
* On loading the products for the product listing we need to apply our BestSeller sorting.
* We encoded the interval in the sorting criteria seperated with the "|" (See js admin component overwrite of "sw-settings-listing-option-criteria-grid")
* Here we 'undo' the postfix and add it as filter for the sorting.
*/
public function onProductListingCriteriaEvent(ProductListingCriteriaEvent $event): void
{
$criteria = $event->getCriteria();
$sortings = $criteria->getSorting();
$criteria->resetSorting();
foreach ($sortings as $sorting) {
$tmp = explode('|', $sorting->getField());
if (count($tmp) > 1 && $tmp[0] === 'extensions.wbfkProductSales.sales') {
$criteria->addSorting(new FieldSorting($tmp[0], $sorting->getDirection(), $sorting->getNaturalSorting()));
$criteria->addAssociation('extensions.wbfkProductSales');
$criteria->addFilter(new OrFilter([
new EqualsFilter('extensions.wbfkProductSales.interval', $tmp[1]),
// If there are no sales in the timeframe (null) we still want to show the product in the product listing
new EqualsFilter('extensions.wbfkProductSales.interval', null),
]));
} else {
$criteria->addSorting($sorting);
}
}
}
/**
* Calculate the sales numbers for every configured interval
*
* @param RecalculateTopSeller $message
* @return void
* @throws \DateInvalidOperationException
* @throws \DateMalformedIntervalStringException
* @throws Exception
*/
public function recalculateProductSales(RecalculateTopSeller $message): void
{
$requestedCsIds = $message->salesChannelIds;
$criteria = new Criteria($requestedCsIds ?: null);
$criteria->addFilter(new EqualsFilter('typeId', Defaults::SALES_CHANNEL_TYPE_STOREFRONT));
$context = Context::createDefaultContext();
$salesChannelIds = $this->salesChannelRepository->searchIds($criteria, $context)->getIds();
$sql = <<<SQL
INSERT INTO wbfk_product_sales (id, product_id, product_version_id, sales_channel_id, `interval`, sales, created_at)
SELECT
UNHEX(REPLACE(uuid(),'-','')),
order_line_item.product_id,
:version_id,
:sales_channel_id,
:interval,
SUM(order_line_item.quantity),
NOW()
FROM order_line_item
INNER JOIN `order` ON `order`.id = order_line_item.order_id AND `order`.version_id = order_line_item.order_version_id
INNER JOIN state_machine_state ON state_machine_state.id = `order`.state_id AND state_machine_state.technical_name = :completed_state
WHERE order_line_item.type = :type
AND order_line_item.version_id = :version_id
AND order_line_item.product_id IS NOT NULL
AND `order`.order_date > :from_date
AND `order`.sales_channel_id = :sales_channel_id
GROUP BY product_id;
SQL;
$unitMap = [
'year' => 'Y',
'month' => 'M',
'day' => 'D',
'week' => 'W',
];
$now = new DateTime();
foreach ($salesChannelIds as $salesChannelId) {
$this->connection->executeStatement(
'DELETE FROM wbfk_product_sales WHERE sales_channel_id = :sales_channel_id;',
['sales_channel_id' => Uuid::fromHexToBytes($salesChannelId)]
);
$topSellerIntervals = $this->systemConfigService->get('WbfkExtensions.config.topseller', $salesChannelId);
foreach ($topSellerIntervals as $topSellerInterval) {
$interval = 'P'.$topSellerInterval['value'].$unitMap[$topSellerInterval['unit']];
$topSellerIntervallStart = $now->sub(new DateInterval($interval));
$this->connection->executeStatement($sql, [
'sales_channel_id' => Uuid::fromHexToBytes($salesChannelId),
'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
'interval' => $interval,
'type' => LineItem::PRODUCT_LINE_ITEM_TYPE,
'completed_state' => OrderStates::STATE_COMPLETED,
'from_date' => $topSellerIntervallStart->format('Y-m-d'),
]);
}
}
}
}