133 lines
4.6 KiB
PHP
133 lines
4.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\OrderCleanup\Cron;
|
|
|
|
use Esun\OrderCleanup\Model\Config;
|
|
use Magento\Framework\Lock\LockManagerInterface;
|
|
use Magento\Framework\Stdlib\DateTime\DateTime;
|
|
use Magento\Sales\Api\OrderManagementInterface;
|
|
use Magento\Sales\Api\OrderRepositoryInterface;
|
|
use Magento\Sales\Model\ResourceModel\Order\CollectionFactory;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class CancelExpiredPendingOrders
|
|
{
|
|
private const LOCK_NAME = 'esun_cancel_expired_pending_orders';
|
|
private const BATCH_SIZE = 100;
|
|
private const ORDER_STATUS_PENDING = 'pending';
|
|
|
|
/**
|
|
* @param Config $config
|
|
* @param CollectionFactory $orderCollectionFactory
|
|
* @param OrderRepositoryInterface $orderRepository
|
|
* @param OrderManagementInterface $orderManagement
|
|
* @param DateTime $dateTime
|
|
* @param LockManagerInterface $lockManager
|
|
* @param LoggerInterface $logger
|
|
*/
|
|
public function __construct(
|
|
private readonly Config $config,
|
|
private readonly CollectionFactory $orderCollectionFactory,
|
|
private readonly OrderRepositoryInterface $orderRepository,
|
|
private readonly OrderManagementInterface $orderManagement,
|
|
private readonly DateTime $dateTime,
|
|
private readonly LockManagerInterface $lockManager,
|
|
private readonly LoggerInterface $logger
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Cancel globally configured expired pending orders.
|
|
*/
|
|
public function execute(): void
|
|
{
|
|
// Read on every run so an Admin configuration change applies to the next cron execution.
|
|
$lifetimeMinutes = $this->config->getCancellationLifetimeMinutes();
|
|
if ($lifetimeMinutes === 0 || !$this->lockManager->lock(self::LOCK_NAME, 0)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$expiredAt = $this->dateTime->gmtDate(
|
|
'Y-m-d H:i:s',
|
|
$this->dateTime->gmtTimestamp() - ($lifetimeMinutes * 60)
|
|
);
|
|
$this->cancelExpiredOrders($expiredAt, $lifetimeMinutes);
|
|
} finally {
|
|
$this->lockManager->unlock(self::LOCK_NAME);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process candidates in stable ID batches.
|
|
*
|
|
* @param string $expiredAt
|
|
* @param int $lifetimeMinutes
|
|
*/
|
|
private function cancelExpiredOrders(string $expiredAt, int $lifetimeMinutes): void
|
|
{
|
|
$lastEntityId = 0;
|
|
$canceledCount = 0;
|
|
|
|
do {
|
|
$collection = $this->orderCollectionFactory->create();
|
|
$collection->addFieldToSelect('entity_id');
|
|
$collection->addFieldToFilter('entity_id', ['gt' => $lastEntityId]);
|
|
$collection->addFieldToFilter('store_id', ['notnull' => true]);
|
|
$collection->addFieldToFilter('status', self::ORDER_STATUS_PENDING);
|
|
$collection->addFieldToFilter('created_at', ['lteq' => $expiredAt]);
|
|
$collection->addFieldToFilter('total_paid', [['null' => true], ['lteq' => 0]]);
|
|
$collection->setOrder('entity_id', 'asc');
|
|
$collection->setPageSize(self::BATCH_SIZE);
|
|
$collection->setCurPage(1);
|
|
|
|
$orderIds = array_map('intval', $collection->getColumnValues('entity_id'));
|
|
foreach ($orderIds as $orderId) {
|
|
$lastEntityId = $orderId;
|
|
if ($this->cancelIfStillEligible($orderId)) {
|
|
++$canceledCount;
|
|
}
|
|
}
|
|
} while (count($orderIds) === self::BATCH_SIZE);
|
|
|
|
if ($canceledCount > 0) {
|
|
$this->logger->info('Expired pending orders were canceled.', [
|
|
'canceled_count' => $canceledCount,
|
|
'lifetime_minutes' => $lifetimeMinutes,
|
|
'expired_at' => $expiredAt,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload and validate an order immediately before cancellation.
|
|
*
|
|
* @param int $orderId
|
|
*/
|
|
private function cancelIfStillEligible(int $orderId): bool
|
|
{
|
|
try {
|
|
$order = $this->orderRepository->get($orderId);
|
|
|
|
// Recheck mutable conditions after the candidate query to reduce callback/cron races.
|
|
if ((string)$order->getStatus() !== self::ORDER_STATUS_PENDING
|
|
|| (float)$order->getTotalPaid() > 0.0
|
|
|| $order->hasInvoices()
|
|
|| !$order->canCancel()
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return $this->orderManagement->cancel($orderId);
|
|
} catch (\Throwable $exception) {
|
|
$this->logger->error('An expired pending order could not be canceled.', [
|
|
'order_id' => $orderId,
|
|
'exception' => $exception,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|