Files
esun3d/app/code/Esun/OrderCleanup/Cron/CancelExpiredPendingOrders.php
2026-09-15 10:35:56 +08:00

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;
}
}
}