Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions InventoryCatalogAdminUi/etc/adminhtml/system.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?xml version="1.0"?>
<!--
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2018 Adobe
* All Rights Reserved.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
Expand Down Expand Up @@ -54,6 +54,28 @@
<comment>Split sales reservations per source so they affect every stock sharing that
source. Changing this value requires a full inventory reindex.</comment>
</field>
<field id="reconcile_cancel_refund" translate="label" type="select" sortOrder="20" showInDefault="1"
showInWebsite="0" showInStore="0" canRestore="1">
<label>Reconcile on cancel/refund</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<comment>When an order reaches a final state, append any release its cancel/credit-memo
observer failed to write. Bounded so it can never over-release.</comment>
</field>
<field id="reconcile_sweep_enabled" translate="label" type="select" sortOrder="30" showInDefault="1"
showInWebsite="0" showInStore="0" canRestore="1">
<label>Enable reconciliation sweep</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<comment>Periodically reconcile terminal orders whose reservations were never released,
including drift from direct database edits or third-party state changes.</comment>
</field>
<field id="reconcile_sweep_cron" translate="label" type="text" sortOrder="40" showInDefault="1"
showInWebsite="0" showInStore="0" canRestore="1">
<label>Reconciliation sweep schedule (cron expression)</label>
<depends>
<field id="reconcile_sweep_enabled">1</field>
</depends>
<comment>Cron expression controlling how often the reconciliation sweep runs.</comment>
</field>
</group>
</section>
</system>
Expand Down
3 changes: 3 additions & 0 deletions InventoryReservations/etc/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<cataloginventory>
<source_reservations>
<enabled>0</enabled>
<reconcile_cancel_refund>0</reconcile_cancel_refund>
<reconcile_sweep_enabled>0</reconcile_sweep_enabled>
<reconcile_sweep_cron>0 * * * *</reconcile_sweep_cron>
</source_reservations>
</cataloginventory>
</default>
Expand Down
84 changes: 84 additions & 0 deletions InventorySales/Console/Command/ReconcileReservationsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventorySales\Console\Command;

use Magento\InventorySales\Model\SourceReservation\ReconcileReservationsSweep;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Reconcile terminal orders that still carry a residual reservation balance.
* Supports a dry run so the residues can be reviewed before any compensation is
* written.
*/
class ReconcileReservationsCommand extends Command
{
private const OPTION_DRY_RUN = 'dry-run';
private const OPTION_LIMIT = 'limit';
private const DEFAULT_LIMIT = 500;

/**
* @param ReconcileReservationsSweep $reconcileReservationsSweep
* @param string|null $name
*/
public function __construct(
private readonly ReconcileReservationsSweep $reconcileReservationsSweep,
?string $name = null
) {
parent::__construct($name);
}

/**
* @inheritdoc
*/
protected function configure(): void
{
$this->setName('inventory:reservation:reconcile');
$this->setDescription('Reconcile terminal orders whose reservations were never released.');
$this->addOption(
self::OPTION_DRY_RUN,
null,
InputOption::VALUE_NONE,
'Report the residues without writing any compensation.'
);
$this->addOption(
self::OPTION_LIMIT,
null,
InputOption::VALUE_REQUIRED,
'Maximum number of orders to process.',
(string)self::DEFAULT_LIMIT
);
parent::configure();
}

/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$dryRun = (bool)$input->getOption(self::OPTION_DRY_RUN);
$limit = max(1, (int)$input->getOption(self::OPTION_LIMIT));

$result = $this->reconcileReservationsSweep->execute($limit, $dryRun);

$output->writeln(sprintf(
'%s %d order(s), %d compensation(s)%s.',
$dryRun ? 'Would reconcile' : 'Reconciled',
$result['orders'],
$result['compensations'],
$result['stock_ids'] ? ' across stock(s) ' . implode(', ', $result['stock_ids']) : ''
));
if ($result['limit_reached']) {
$output->writeln('<comment>Batch limit reached; run again to process the remaining residues.</comment>');
}

return Command::SUCCESS;
}
}
61 changes: 61 additions & 0 deletions InventorySales/Cron/ReconcileReservations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventorySales\Cron;

use Magento\InventorySales\Model\SourceReservation\ReconcileReservationsSweep;
use Magento\InventorySales\Model\SourceReservation\ReconciliationConfig;
use Psr\Log\LoggerInterface;

/**
* Periodic out-of-band reconciliation sweep. Opt-in; the schedule is read from a
* configurable cron expression. Bounded per run to keep the job predictable; a
* hit limit is logged so a growing backlog is visible instead of silently
* truncated.
*/
class ReconcileReservations
{
private const BATCH_LIMIT = 500;

/**
* @param ReconciliationConfig $reconciliationConfig
* @param ReconcileReservationsSweep $reconcileReservationsSweep
* @param LoggerInterface $logger
*/
public function __construct(
private readonly ReconciliationConfig $reconciliationConfig,
private readonly ReconcileReservationsSweep $reconcileReservationsSweep,
private readonly LoggerInterface $logger
) {
}

/**
* Run the sweep when enabled.
*
* @return void
*/
public function execute(): void
{
if (!$this->reconciliationConfig->isSweepEnabled()) {
return;
}

$result = $this->reconcileReservationsSweep->execute(self::BATCH_LIMIT);
if ($result['orders'] > 0) {
$this->logger->info(
'Source-level reservations: reconciliation sweep healed residual reservations.',
['orders' => $result['orders'], 'compensations' => $result['compensations']]
);
}
if ($result['limit_reached']) {
$this->logger->warning(
'Source-level reservations: reconciliation sweep hit its batch limit; residues remain.',
['limit' => self::BATCH_LIMIT]
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventorySales\Model\ResourceModel\SourceReservation;

use Magento\Framework\App\ResourceConnection;
use Magento\InventoryReservationsApi\Model\ReservationInterface;

/**
* Load the reservation balance of an order keyed by its increment id, taken from
* the dedicated column when present and from the serialized metadata otherwise.
* The increment id is the identifier shared by every sales event of an order
* (placement, shipment, cancel, credit memo); the metadata object_id is not, so
* it cannot be used to net a compensation against its original demand.
*/
class GetOrderReservationBalance
{
/**
* @param ResourceConnection $resourceConnection
*/
public function __construct(
private readonly ResourceConnection $resourceConnection
) {
}

/**
* Get the reservation balance indexed by SKU and source code ('' for rows without a source).
*
* @param string $objectIncrementId
* @param string[] $skus
* @param int $stockId
* @return array<string, array<string, float>> [sku][source_code|''] => SUM(quantity)
*/
public function execute(string $objectIncrementId, array $skus, int $stockId): array
{
if (empty($skus) || $objectIncrementId === '') {
return [];
}

$connection = $this->resourceConnection->getConnection();
$incrementIdExpr = sprintf(
"COALESCE(%s, JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.object_increment_id')))",
ReservationInterface::OBJECT_INCREMENT_ID
);
$select = $connection->select()
->from(
$this->resourceConnection->getTableName('inventory_reservation'),
[
ReservationInterface::SKU,
ReservationInterface::SOURCE_CODE,
'quantity' => 'SUM(' . ReservationInterface::QUANTITY . ')',
]
)
->where(ReservationInterface::STOCK_ID . ' = ?', $stockId)
->where(ReservationInterface::SKU . ' IN (?)', $skus)
->where($incrementIdExpr . ' = ?', $objectIncrementId)
->group([ReservationInterface::SKU, ReservationInterface::SOURCE_CODE]);

$result = [];
foreach ($connection->fetchAll($select) as $row) {
$result[$row[ReservationInterface::SKU]][(string)$row[ReservationInterface::SOURCE_CODE]] =
(float)$row['quantity'];
}

return $result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventorySales\Model\ResourceModel\SourceReservation;

use Magento\Framework\App\ResourceConnection;
use Magento\InventoryReservationsApi\Model\ReservationInterface;

/**
* Load the outstanding negative reservation balance of an order per (stock,
* sku, source), keyed by the increment id (column or metadata). Used by the
* reconciler to find demand that a terminal order never released.
*/
class GetOrderReservationLedger
{
private const EPSILON = 0.000001;

/**
* @param ResourceConnection $resourceConnection
*/
public function __construct(
private readonly ResourceConnection $resourceConnection
) {
}

/**
* Get the negative balances of an order as a list of rows.
*
* @param string $objectIncrementId
* @return array<int, array{stock_id:int, sku:string, source_code:string|null, balance:float}>
*/
public function execute(string $objectIncrementId): array
{
if ($objectIncrementId === '') {
return [];
}

$connection = $this->resourceConnection->getConnection();
$incrementIdExpr = sprintf(
"COALESCE(%s, JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.object_increment_id')))",
ReservationInterface::OBJECT_INCREMENT_ID
);
$select = $connection->select()
->from(
$this->resourceConnection->getTableName('inventory_reservation'),
[
ReservationInterface::STOCK_ID,
ReservationInterface::SKU,
ReservationInterface::SOURCE_CODE,
'balance' => 'SUM(' . ReservationInterface::QUANTITY . ')',
]
)
->where($incrementIdExpr . ' = ?', $objectIncrementId)
->group([ReservationInterface::STOCK_ID, ReservationInterface::SKU, ReservationInterface::SOURCE_CODE])
->having('SUM(' . ReservationInterface::QUANTITY . ') < ?', -self::EPSILON);

$rows = [];
foreach ($connection->fetchAll($select) as $row) {
$rows[] = [
'stock_id' => (int)$row[ReservationInterface::STOCK_ID],
'sku' => (string)$row[ReservationInterface::SKU],
'source_code' => $row[ReservationInterface::SOURCE_CODE] !== null
? (string)$row[ReservationInterface::SOURCE_CODE]
: null,
'balance' => (float)$row['balance'],
];
}

return $rows;
}
}
Loading
Loading