From 3aff1a7510aa524f26cf9eae0a1da531c3da9b44 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Mon, 3 Aug 2026 07:10:00 -0400 Subject: [PATCH 1/3] feat(inventory-sales): resolve the per-source availability of a list of skus --- .../GetSourceItemDataBySkusAndSources.php | 67 ++++++++ .../GetSourceAvailabilityBySkus.php | 93 +++++++++++ .../GetSourceAvailabilityBySkusTest.php | 153 ++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php create mode 100644 InventorySales/Model/SourceReservation/GetSourceAvailabilityBySkus.php create mode 100644 InventorySales/Test/Unit/Model/SourceReservation/GetSourceAvailabilityBySkusTest.php diff --git a/InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php b/InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php new file mode 100644 index 00000000000..75b4b53b6ae --- /dev/null +++ b/InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php @@ -0,0 +1,67 @@ +>> [source_code][sku] => [quantity, status] + */ + public function execute(array $skus, array $sourceCodes): array + { + if (empty($skus) || empty($sourceCodes)) { + return []; + } + + $connection = $this->resourceConnection->getConnection(); + $select = $connection->select() + ->from( + $this->resourceConnection->getTableName(SourceItem::TABLE_NAME_SOURCE_ITEM), + [ + SourceItemInterface::SOURCE_CODE, + SourceItemInterface::SKU, + SourceItemInterface::QUANTITY, + SourceItemInterface::STATUS, + ] + ) + ->where(SourceItemInterface::SOURCE_CODE . ' IN (?)', $sourceCodes) + ->where(SourceItemInterface::SKU . ' IN (?)', $skus); + + $result = []; + foreach ($connection->fetchAll($select) as $row) { + $result[$row[SourceItemInterface::SOURCE_CODE]][$row[SourceItemInterface::SKU]] = [ + 'quantity' => (float) $row[SourceItemInterface::QUANTITY], + 'status' => (int) $row[SourceItemInterface::STATUS], + ]; + } + + return $result; + } +} diff --git a/InventorySales/Model/SourceReservation/GetSourceAvailabilityBySkus.php b/InventorySales/Model/SourceReservation/GetSourceAvailabilityBySkus.php new file mode 100644 index 00000000000..2f569535c7f --- /dev/null +++ b/InventorySales/Model/SourceReservation/GetSourceAvailabilityBySkus.php @@ -0,0 +1,93 @@ +>> + */ + public function execute(array $skus, array $sourceCodes): array + { + if (empty($skus) || empty($sourceCodes)) { + return []; + } + + $sourceItems = $this->getSourceItemData->execute($skus, $sourceCodes); + $reservations = $this->sourceReservationsConfig->isEnabled() + ? $this->getReservationsQuantity->execute($skus, $sourceCodes) + : []; + + $availability = []; + foreach ($sourceItems as $sourceCode => $sourceItemsBySku) { + foreach ($sourceItemsBySku as $sku => $sourceItem) { + $availability[$sourceCode][$sku] = $this->buildRow( + (float) $sourceItem['quantity'], + (int) $sourceItem['status'], + (float) ($reservations[$sourceCode][$sku] ?? 0.0) + ); + } + } + + return $availability; + } + + /** + * Build the availability row of a single source item. + * + * @param float $physical + * @param int $status + * @param float $reserved + * @return array + */ + private function buildRow(float $physical, int $status, float $reserved): array + { + $outOfStock = $status === SourceItemInterface::STATUS_OUT_OF_STOCK; + + return [ + 'physical' => $physical, + 'reserved' => $reserved, + 'salable' => $outOfStock ? 0.0 : max(0.0, $physical + $reserved), + 'source_item_out_of_stock' => $outOfStock, + ]; + } +} diff --git a/InventorySales/Test/Unit/Model/SourceReservation/GetSourceAvailabilityBySkusTest.php b/InventorySales/Test/Unit/Model/SourceReservation/GetSourceAvailabilityBySkusTest.php new file mode 100644 index 00000000000..13241b3beb1 --- /dev/null +++ b/InventorySales/Test/Unit/Model/SourceReservation/GetSourceAvailabilityBySkusTest.php @@ -0,0 +1,153 @@ +getSourceItemData = $this->createMock(GetSourceItemDataBySkusAndSources::class); + $this->getReservationsQuantity = $this->createMock(GetReservationsQuantityBySkusAndSources::class); + $this->sourceReservationsConfig = $this->createMock(SourceReservationsConfig::class); + + $this->getSourceAvailability = new GetSourceAvailabilityBySkus( + $this->getSourceItemData, + $this->getReservationsQuantity, + $this->sourceReservationsConfig + ); + } + + public function testNetsThePhysicalQuantityAgainstTheSourceReservationBalance(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getSourceItemData->method('execute')->willReturn( + ['src_a' => ['sku-1' => $this->sourceItem(5.0, SourceItemInterface::STATUS_IN_STOCK)]] + ); + $this->getReservationsQuantity->method('execute')->willReturn(['src_a' => ['sku-1' => -2.0]]); + + $result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']); + + self::assertSame(5.0, $result['src_a']['sku-1']['physical']); + self::assertSame(-2.0, $result['src_a']['sku-1']['reserved']); + self::assertSame(3.0, $result['src_a']['sku-1']['salable']); + self::assertFalse($result['src_a']['sku-1']['source_item_out_of_stock']); + } + + public function testDegradesToThePhysicalQuantityWhenSourceReservationsAreDisabled(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(false); + $this->getSourceItemData->method('execute')->willReturn( + ['src_a' => ['sku-1' => $this->sourceItem(7.0, SourceItemInterface::STATUS_IN_STOCK)]] + ); + $this->getReservationsQuantity->expects(self::never())->method('execute'); + + $result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']); + + self::assertSame(7.0, $result['src_a']['sku-1']['physical']); + self::assertSame(0.0, $result['src_a']['sku-1']['reserved']); + self::assertSame(7.0, $result['src_a']['sku-1']['salable']); + } + + public function testReportsZeroSalableButKeepsThePhysicalQuantityWhenTheSourceItemIsOutOfStock(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getSourceItemData->method('execute')->willReturn( + ['src_a' => ['sku-1' => $this->sourceItem(10.0, SourceItemInterface::STATUS_OUT_OF_STOCK)]] + ); + $this->getReservationsQuantity->method('execute')->willReturn([]); + + $result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']); + + self::assertSame(10.0, $result['src_a']['sku-1']['physical']); + self::assertSame(0.0, $result['src_a']['sku-1']['salable']); + self::assertTrue($result['src_a']['sku-1']['source_item_out_of_stock']); + } + + public function testClampsTheSalableQuantityToZeroWhenReservationsExceedThePhysicalQuantity(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getSourceItemData->method('execute')->willReturn( + ['src_a' => ['sku-1' => $this->sourceItem(3.0, SourceItemInterface::STATUS_IN_STOCK)]] + ); + $this->getReservationsQuantity->method('execute')->willReturn(['src_a' => ['sku-1' => -8.0]]); + + $result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']); + + self::assertSame(-8.0, $result['src_a']['sku-1']['reserved']); + self::assertSame(0.0, $result['src_a']['sku-1']['salable']); + } + + public function testOmitsSourcesWithoutASourceItemForTheSku(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getSourceItemData->method('execute')->willReturn( + ['src_a' => ['sku-1' => $this->sourceItem(1.0, SourceItemInterface::STATUS_IN_STOCK)]] + ); + $this->getReservationsQuantity->method('execute')->willReturn(['src_b' => ['sku-1' => -4.0]]); + + $result = $this->getSourceAvailability->execute(['sku-1'], ['src_a', 'src_b']); + + self::assertArrayNotHasKey('src_b', $result); + } + + public function testQueriesNothingWhenTheSkuListIsEmpty(): void + { + $this->getSourceItemData->expects(self::never())->method('execute'); + $this->getReservationsQuantity->expects(self::never())->method('execute'); + + self::assertSame([], $this->getSourceAvailability->execute([], ['src_a'])); + } + + public function testQueriesNothingWhenTheSourceListIsEmpty(): void + { + $this->getSourceItemData->expects(self::never())->method('execute'); + $this->getReservationsQuantity->expects(self::never())->method('execute'); + + self::assertSame([], $this->getSourceAvailability->execute(['sku-1'], [])); + } + + /** + * Build a source item row as returned by the resource model. + * + * @param float $quantity + * @param int $status + * @return array + */ + private function sourceItem(float $quantity, int $status): array + { + return ['quantity' => $quantity, 'status' => $status]; + } +} From 2d8ab491b974173fdea71a19e65c1aba3bf42fe5 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Mon, 3 Aug 2026 07:24:00 -0400 Subject: [PATCH 2/3] feat(inventory-sales-admin-ui): break the admin salable quantity down by source --- .../AddSourceSalableQuantityBreakdown.php | 102 +++++++++ .../Model/GetSourceSalableQuantityData.php | 147 +++++++++++++ .../AddSourceSalableQuantityBreakdownTest.php | 171 +++++++++++++++ .../GetSourceSalableQuantityDataTest.php | 197 ++++++++++++++++++ .../Listing/Column/SalableQuantityTest.php | 117 ++++++++++- .../Form/Modifier/SalableQuantityTest.php | 110 ++++++++++ .../Listing/Column/SalableQuantity.php | 60 +++++- .../Product/Form/Modifier/SalableQuantity.php | 24 ++- InventorySalesAdminUi/composer.json | 2 + InventorySalesAdminUi/i18n/en_US.csv | 10 +- .../adminhtml/ui_component/product_form.xml | 2 +- .../adminhtml/web/css/source/_module.less | 64 ++++++ .../js/product/grid/cell/salable-quantity.js | 60 +++++- .../product/form/salable-quantity.html | 69 +++++- .../product/grid/cell/salable-quantity.html | 55 +++++ 15 files changed, 1154 insertions(+), 36 deletions(-) create mode 100644 InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php create mode 100644 InventorySalesAdminUi/Model/GetSourceSalableQuantityData.php create mode 100644 InventorySalesAdminUi/Test/Unit/Model/AddSourceSalableQuantityBreakdownTest.php create mode 100644 InventorySalesAdminUi/Test/Unit/Model/GetSourceSalableQuantityDataTest.php create mode 100644 InventorySalesAdminUi/Test/Unit/Ui/DataProvider/Product/Form/Modifier/SalableQuantityTest.php diff --git a/InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php b/InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php new file mode 100644 index 00000000000..a49ac1467d0 --- /dev/null +++ b/InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php @@ -0,0 +1,102 @@ +>> $stockEntriesBySku + * @return array>> + */ + public function execute(array $stockEntriesBySku): array + { + if ($this->isSingleSourceMode->execute() === true) { + return $stockEntriesBySku; + } + + $breakdown = $this->getSourceSalableQuantityData->execute( + $this->collectBreakableSkus($stockEntriesBySku) + ); + $sourceReservationsEnabled = $this->sourceReservationsConfig->isEnabled(); + + foreach ($stockEntriesBySku as $sku => $stockEntries) { + foreach ($stockEntries as $key => $stockEntry) { + if (!$this->isBreakable($stockEntry)) { + continue; + } + + $stockId = (int) $stockEntry['stock_id']; + $stockEntriesBySku[$sku][$key]['sources'] = $breakdown[$sku][$stockId] ?? []; + $stockEntriesBySku[$sku][$key]['source_reservations_enabled'] = $sourceReservationsEnabled; + } + } + + return $stockEntriesBySku; + } + + /** + * Collect the SKUs carrying at least one stock entry worth breaking down. + * + * @param array>> $stockEntriesBySku + * @return string[] + */ + private function collectBreakableSkus(array $stockEntriesBySku): array + { + $skus = []; + foreach ($stockEntriesBySku as $sku => $stockEntries) { + foreach ($stockEntries as $stockEntry) { + if ($this->isBreakable($stockEntry)) { + $skus[] = (string) $sku; + break; + } + } + } + + return $skus; + } + + /** + * Check whether a stock entry reports a quantity that can be broken down by source. + * + * Entries standing in for too many stocks carry a message instead of a stock, and entries + * without managed stock report no quantity at all. + * + * @param array $stockEntry + * @return bool + */ + private function isBreakable(array $stockEntry): bool + { + return isset($stockEntry['stock_id']) && !empty($stockEntry['manage_stock']); + } +} diff --git a/InventorySalesAdminUi/Model/GetSourceSalableQuantityData.php b/InventorySalesAdminUi/Model/GetSourceSalableQuantityData.php new file mode 100644 index 00000000000..b02f73657fa --- /dev/null +++ b/InventorySalesAdminUi/Model/GetSourceSalableQuantityData.php @@ -0,0 +1,147 @@ +>>> + */ + public function execute(array $skus): array + { + if (empty($skus)) { + return []; + } + + $stockIdsBySku = []; + $sourcesByStockId = []; + foreach ($skus as $sku) { + $stockIds = array_map('intval', $this->getAssignedStockIdsBySku->execute((string) $sku)); + $stockIdsBySku[(string) $sku] = $stockIds; + $sourcesByStockId = $this->addSourcesOfStocks($stockIds, $sourcesByStockId); + } + + $availability = $this->getSourceAvailability->execute( + $skus, + $this->collectSourceCodes($sourcesByStockId) + ); + + $data = []; + foreach ($stockIdsBySku as $sku => $stockIds) { + foreach ($stockIds as $stockId) { + $rows = $this->buildRows($sourcesByStockId[$stockId], (string) $sku, $availability); + if ($rows) { + $data[$sku][$stockId] = $rows; + } + } + } + + return $data; + } + + /** + * Resolve the sources of the given stocks, keeping the ones already resolved. + * + * @param int[] $stockIds + * @param array $sourcesByStockId + * @return array + */ + private function addSourcesOfStocks(array $stockIds, array $sourcesByStockId): array + { + foreach ($stockIds as $stockId) { + if (!isset($sourcesByStockId[$stockId])) { + $sourcesByStockId[$stockId] = $this->getSourcesAssignedToStock->execute($stockId); + } + } + + return $sourcesByStockId; + } + + /** + * Collect the distinct source codes of every resolved stock. + * + * @param array $sourcesByStockId + * @return string[] + */ + private function collectSourceCodes(array $sourcesByStockId): array + { + $sourceCodes = []; + foreach ($sourcesByStockId as $sources) { + foreach ($sources as $source) { + $sourceCodes[(string) $source->getSourceCode()] = true; + } + } + + return array_keys($sourceCodes); + } + + /** + * Build the breakdown rows of a SKU on the sources of one stock. + * + * Sources the SKU has no source item on are skipped: they hold nothing to report. + * + * @param SourceInterface[] $sources + * @param string $sku + * @param array>> $availability + * @return array> + */ + private function buildRows(array $sources, string $sku, array $availability): array + { + $rows = []; + foreach ($sources as $source) { + $sourceCode = (string) $source->getSourceCode(); + if (!isset($availability[$sourceCode][$sku])) { + continue; + } + + $isEnabled = (bool) $source->isEnabled(); + $sourceAvailability = $availability[$sourceCode][$sku]; + $rows[] = [ + 'source_code' => $sourceCode, + 'source_name' => $source->getName() ?: $sourceCode, + 'physical' => (float) $sourceAvailability['physical'], + 'reserved' => (float) $sourceAvailability['reserved'], + 'salable' => $isEnabled ? (float) $sourceAvailability['salable'] : 0.0, + 'source_enabled' => $isEnabled, + 'source_item_out_of_stock' => (bool) $sourceAvailability['source_item_out_of_stock'], + ]; + } + + return $rows; + } +} diff --git a/InventorySalesAdminUi/Test/Unit/Model/AddSourceSalableQuantityBreakdownTest.php b/InventorySalesAdminUi/Test/Unit/Model/AddSourceSalableQuantityBreakdownTest.php new file mode 100644 index 00000000000..1774b59e8db --- /dev/null +++ b/InventorySalesAdminUi/Test/Unit/Model/AddSourceSalableQuantityBreakdownTest.php @@ -0,0 +1,171 @@ +getSourceSalableQuantityData = $this->createMock(GetSourceSalableQuantityData::class); + $this->isSingleSourceMode = $this->createMock(IsSingleSourceModeInterface::class); + $this->sourceReservationsConfig = $this->createMock(SourceReservationsConfig::class); + + $this->addSourceBreakdown = new AddSourceSalableQuantityBreakdown( + $this->getSourceSalableQuantityData, + $this->isSingleSourceMode, + $this->sourceReservationsConfig + ); + } + + public function testAddsTheBreakdownToTheStockEntryItBelongsTo(): void + { + $this->getSourceSalableQuantityData->method('execute')->willReturn([ + 'sku-1' => [ + 2 => [$this->sourceRow('src_a')], + 3 => [$this->sourceRow('src_b')], + ], + ]); + + $entries = $this->addSourceBreakdown->execute([ + 'sku-1' => [$this->stockEntry(2), $this->stockEntry(3)], + ])['sku-1']; + + self::assertSame('src_a', $entries[0]['sources'][0]['source_code']); + self::assertSame('src_b', $entries[1]['sources'][0]['source_code']); + } + + public function testReportsAnEmptyBreakdownForAStockWithoutSourceRows(): void + { + $this->getSourceSalableQuantityData->method('execute')->willReturn([]); + + $entries = $this->addSourceBreakdown->execute(['sku-1' => [$this->stockEntry(2)]])['sku-1']; + + self::assertSame([], $entries[0]['sources']); + } + + public function testReturnsTheEntriesUntouchedInSingleSourceMode(): void + { + $this->isSingleSourceMode->method('execute')->willReturn(true); + $this->getSourceSalableQuantityData->expects(self::never())->method('execute'); + $entriesBySku = ['sku-1' => [$this->stockEntry(1)]]; + + self::assertSame($entriesBySku, $this->addSourceBreakdown->execute($entriesBySku)); + } + + public function testSkipsAStockEntryThatDoesNotManageStock(): void + { + $this->getSourceSalableQuantityData->method('execute')->willReturn([ + 'sku-1' => [2 => [$this->sourceRow('src_a')]], + ]); + $entry = $this->stockEntry(2); + $entry['manage_stock'] = false; + + $entries = $this->addSourceBreakdown->execute(['sku-1' => [$entry]])['sku-1']; + + self::assertArrayNotHasKey('sources', $entries[0]); + } + + public function testSkipsAnEntryStandingInForTooManyStocks(): void + { + $this->getSourceSalableQuantityData->expects(self::once()) + ->method('execute') + ->with([]) + ->willReturn([]); + + $entries = $this->addSourceBreakdown->execute([ + 'sku-1' => [['manage_stock' => true, 'message' => 'Associated to 3 stocks']], + ])['sku-1']; + + self::assertArrayNotHasKey('sources', $entries[0]); + } + + public function testResolvesEveryBreakableSkuInASingleCall(): void + { + $this->getSourceSalableQuantityData->expects(self::once()) + ->method('execute') + ->with(['sku-1', 'sku-2']) + ->willReturn([]); + + $this->addSourceBreakdown->execute([ + 'sku-1' => [$this->stockEntry(2)], + 'sku-2' => [$this->stockEntry(2)], + ]); + } + + public function testFlagsWhetherSourceReservationsAreEnabled(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getSourceSalableQuantityData->method('execute')->willReturn([]); + + $entries = $this->addSourceBreakdown->execute(['sku-1' => [$this->stockEntry(2)]])['sku-1']; + + self::assertTrue($entries[0]['source_reservations_enabled']); + } + + /** + * Build an aggregated stock entry as returned by GetSalableQuantityDataBySku. + * + * @param int $stockId + * @return array + */ + private function stockEntry(int $stockId): array + { + return [ + 'stock_id' => $stockId, + 'stock_name' => 'Stock ' . $stockId, + 'qty' => 10.0, + 'manage_stock' => true, + ]; + } + + /** + * Build a breakdown row as returned by GetSourceSalableQuantityData. + * + * @param string $sourceCode + * @return array + */ + private function sourceRow(string $sourceCode): array + { + return [ + 'source_code' => $sourceCode, + 'source_name' => strtoupper($sourceCode), + 'physical' => 5.0, + 'reserved' => 0.0, + 'salable' => 5.0, + 'source_enabled' => true, + 'source_item_out_of_stock' => false, + ]; + } +} diff --git a/InventorySalesAdminUi/Test/Unit/Model/GetSourceSalableQuantityDataTest.php b/InventorySalesAdminUi/Test/Unit/Model/GetSourceSalableQuantityDataTest.php new file mode 100644 index 00000000000..d9b56594c51 --- /dev/null +++ b/InventorySalesAdminUi/Test/Unit/Model/GetSourceSalableQuantityDataTest.php @@ -0,0 +1,197 @@ +getAssignedStockIdsBySku = $this->createMock(GetAssignedStockIdsBySku::class); + $this->getSourcesAssignedToStock = $this->createMock( + GetSourcesAssignedToStockOrderedByPriorityInterface::class + ); + $this->getSourceAvailability = $this->createMock(GetSourceAvailabilityBySkus::class); + + $this->getSourceSalableQuantityData = new GetSourceSalableQuantityData( + $this->getAssignedStockIdsBySku, + $this->getSourcesAssignedToStock, + $this->getSourceAvailability + ); + } + + public function testGroupsTheSourceRowsUnderTheStockTheyAreAssignedTo(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2, 3]); + $this->getSourcesAssignedToStock->method('execute')->willReturnMap([ + [2, [$this->source('src_a', 'Source A')]], + [3, [$this->source('src_b', 'Source B')]], + ]); + $this->getSourceAvailability->method('execute')->willReturn([ + 'src_a' => ['sku-1' => $this->availability(5.0, -2.0, 3.0)], + 'src_b' => ['sku-1' => $this->availability(10.0, 0.0, 10.0)], + ]); + + $result = $this->getSourceSalableQuantityData->execute(['sku-1']); + + self::assertSame(['src_a'], array_column($result['sku-1'][2], 'source_code')); + self::assertSame(['src_b'], array_column($result['sku-1'][3], 'source_code')); + self::assertSame(3.0, $result['sku-1'][2][0]['salable']); + self::assertSame(-2.0, $result['sku-1'][2][0]['reserved']); + self::assertSame('Source A', $result['sku-1'][2][0]['source_name']); + } + + public function testKeepsThePriorityOrderOfTheSourcesAssignedToTheStock(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2]); + $this->getSourcesAssignedToStock->method('execute')->willReturn([ + $this->source('src_b', 'Source B'), + $this->source('src_a', 'Source A'), + ]); + $this->getSourceAvailability->method('execute')->willReturn([ + 'src_a' => ['sku-1' => $this->availability(1.0, 0.0, 1.0)], + 'src_b' => ['sku-1' => $this->availability(2.0, 0.0, 2.0)], + ]); + + $result = $this->getSourceSalableQuantityData->execute(['sku-1']); + + self::assertSame(['src_b', 'src_a'], array_column($result['sku-1'][2], 'source_code')); + } + + public function testReportsADisabledSourceAsNotSalableWhileKeepingItsQuantities(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2]); + $this->getSourcesAssignedToStock->method('execute')->willReturn([ + $this->source('src_a', 'Source A', false), + ]); + $this->getSourceAvailability->method('execute')->willReturn([ + 'src_a' => ['sku-1' => $this->availability(9.0, -1.0, 8.0)], + ]); + + $row = $this->getSourceSalableQuantityData->execute(['sku-1'])['sku-1'][2][0]; + + self::assertFalse($row['source_enabled']); + self::assertSame(9.0, $row['physical']); + self::assertSame(-1.0, $row['reserved']); + self::assertSame(0.0, $row['salable']); + } + + public function testOmitsASourceOfTheStockThatHasNoSourceItemForTheSku(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2]); + $this->getSourcesAssignedToStock->method('execute')->willReturn([ + $this->source('src_a', 'Source A'), + $this->source('src_b', 'Source B'), + ]); + $this->getSourceAvailability->method('execute')->willReturn([ + 'src_a' => ['sku-1' => $this->availability(4.0, 0.0, 4.0)], + ]); + + $result = $this->getSourceSalableQuantityData->execute(['sku-1']); + + self::assertSame(['src_a'], array_column($result['sku-1'][2], 'source_code')); + } + + public function testResolvesTheAvailabilityOfEverySkuAndSourceInASingleCall(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2]); + $this->getSourcesAssignedToStock->method('execute')->willReturn([ + $this->source('src_a', 'Source A'), + $this->source('src_b', 'Source B'), + ]); + $this->getSourceAvailability->expects(self::once()) + ->method('execute') + ->with(['sku-1', 'sku-2'], ['src_a', 'src_b']) + ->willReturn([]); + + $this->getSourceSalableQuantityData->execute(['sku-1', 'sku-2']); + } + + public function testResolvesTheSourcesOfAStockOnlyOnceAcrossSkus(): void + { + $this->getAssignedStockIdsBySku->method('execute')->willReturn([2]); + $this->getSourcesAssignedToStock->expects(self::once()) + ->method('execute') + ->with(2) + ->willReturn([$this->source('src_a', 'Source A')]); + $this->getSourceAvailability->method('execute')->willReturn([]); + + $this->getSourceSalableQuantityData->execute(['sku-1', 'sku-2']); + } + + public function testResolvesNothingWhenTheSkuListIsEmpty(): void + { + $this->getAssignedStockIdsBySku->expects(self::never())->method('execute'); + $this->getSourceAvailability->expects(self::never())->method('execute'); + + self::assertSame([], $this->getSourceSalableQuantityData->execute([])); + } + + /** + * Build a source of the stock. + * + * @param string $sourceCode + * @param string $name + * @param bool $enabled + * @return SourceInterface|MockObject + */ + private function source(string $sourceCode, string $name, bool $enabled = true) + { + $source = $this->createMock(SourceInterface::class); + $source->method('getSourceCode')->willReturn($sourceCode); + $source->method('getName')->willReturn($name); + $source->method('isEnabled')->willReturn($enabled); + + return $source; + } + + /** + * Build an availability row as returned by the availability service. + * + * @param float $physical + * @param float $reserved + * @param float $salable + * @return array + */ + private function availability(float $physical, float $reserved, float $salable): array + { + return [ + 'physical' => $physical, + 'reserved' => $reserved, + 'salable' => $salable, + 'source_item_out_of_stock' => false, + ]; + } +} diff --git a/InventorySalesAdminUi/Test/Unit/Ui/Component/Listing/Column/SalableQuantityTest.php b/InventorySalesAdminUi/Test/Unit/Ui/Component/Listing/Column/SalableQuantityTest.php index cde1c98bab1..baac951ec2a 100644 --- a/InventorySalesAdminUi/Test/Unit/Ui/Component/Listing/Column/SalableQuantityTest.php +++ b/InventorySalesAdminUi/Test/Unit/Ui/Component/Listing/Column/SalableQuantityTest.php @@ -9,8 +9,8 @@ use Magento\Framework\View\Element\UiComponent\ContextInterface; use Magento\Framework\View\Element\UiComponentFactory; -use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface; use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; +use Magento\InventorySalesAdminUi\Model\AddSourceSalableQuantityBreakdown; use Magento\InventorySalesAdminUi\Model\GetSalableQuantityDataBySku; use Magento\InventorySalesAdminUi\Model\ResourceModel\GetAssignedStockIdsBySku; use Magento\InventorySalesAdminUi\Ui\Component\Listing\Column\SalableQuantity; @@ -34,11 +34,6 @@ class SalableQuantityTest extends TestCase */ private $isSourceItemManagementAllowedForProductTypeMock; - /** - * @var IsSingleSourceModeInterface|MockObject - */ - private $isSingleSourceModeMock; - /** * @var GetSalableQuantityDataBySku|MockObject */ @@ -49,6 +44,11 @@ class SalableQuantityTest extends TestCase */ private $getAssignedStockIdsBySkuMock; + /** + * @var AddSourceSalableQuantityBreakdown|MockObject + */ + private $addSourceSalableQuantityBreakdownMock; + /** * @var SalableQuantity */ @@ -61,17 +61,18 @@ protected function setUp(): void $this->isSourceItemManagementAllowedForProductTypeMock = $this->createMock( IsSourceItemManagementAllowedForProductTypeInterface::class ); - $this->isSingleSourceModeMock = $this->createMock(IsSingleSourceModeInterface::class); $this->getSalableQuantityDataBySkuMock = $this->createMock(GetSalableQuantityDataBySku::class); $this->getAssignedStockIdsBySkuMock = $this->createMock(GetAssignedStockIdsBySku::class); + $this->addSourceSalableQuantityBreakdownMock = $this->createMock(AddSourceSalableQuantityBreakdown::class); + $this->addSourceSalableQuantityBreakdownMock->method('execute')->willReturnArgument(0); $this->salableQuantity = new SalableQuantity( $this->contextMock, $this->uiComponentFactoryMock, $this->isSourceItemManagementAllowedForProductTypeMock, - $this->isSingleSourceModeMock, $this->getSalableQuantityDataBySkuMock, $this->getAssignedStockIdsBySkuMock, + $this->addSourceSalableQuantityBreakdownMock, 2 ); } @@ -148,4 +149,104 @@ public function testPrepareDataSource(): void ]; self::assertEquals($expectedDataSource, $dataSource); } + + public function testHandsEveryRowOfThePageToTheBreakdownInOneCall(): void + { + $this->isSourceItemManagementAllowedForProductTypeMock->method('execute')->willReturn(true); + $this->getAssignedStockIdsBySkuMock->method('execute')->willReturn([2]); + $stockEntries = [['stock_id' => 2, 'stock_name' => 'Stock 2', 'qty' => 1, 'manage_stock' => true]]; + $this->getSalableQuantityDataBySkuMock->method('execute')->willReturn($stockEntries); + + $this->addSourceSalableQuantityBreakdownMock = $this->createMock(AddSourceSalableQuantityBreakdown::class); + $this->addSourceSalableQuantityBreakdownMock->expects(self::once()) + ->method('execute') + ->with(['product1' => $stockEntries, 'product2' => $stockEntries]) + ->willReturnArgument(0); + $column = new SalableQuantity( + $this->contextMock, + $this->uiComponentFactoryMock, + $this->isSourceItemManagementAllowedForProductTypeMock, + $this->getSalableQuantityDataBySkuMock, + $this->getAssignedStockIdsBySkuMock, + $this->addSourceSalableQuantityBreakdownMock, + 2 + ); + + $column->prepareDataSource([ + 'data' => [ + 'totalRecords' => 2, + 'items' => [ + ['sku' => 'product1', 'type_id' => 'simple'], + ['sku' => 'product2', 'type_id' => 'simple'], + ], + ], + ]); + } + + public function testRendersTheBreakdownResolvedForTheRow(): void + { + $this->isSourceItemManagementAllowedForProductTypeMock->method('execute')->willReturn(true); + $this->getAssignedStockIdsBySkuMock->method('execute')->willReturn([2]); + $this->getSalableQuantityDataBySkuMock->method('execute')->willReturn( + [['stock_id' => 2, 'stock_name' => 'Stock 2', 'qty' => 8, 'manage_stock' => true]] + ); + + $brokenDown = $this->createMock(AddSourceSalableQuantityBreakdown::class); + $brokenDown->method('execute')->willReturn([ + 'product1' => [ + [ + 'stock_id' => 2, + 'stock_name' => 'Stock 2', + 'qty' => 8, + 'manage_stock' => true, + 'sources' => [['source_code' => 'src_a', 'salable' => 8.0]], + 'source_reservations_enabled' => true, + ], + ], + ]); + $column = new SalableQuantity( + $this->contextMock, + $this->uiComponentFactoryMock, + $this->isSourceItemManagementAllowedForProductTypeMock, + $this->getSalableQuantityDataBySkuMock, + $this->getAssignedStockIdsBySkuMock, + $brokenDown, + 2 + ); + + $dataSource = $column->prepareDataSource([ + 'data' => ['totalRecords' => 1, 'items' => [['sku' => 'product1', 'type_id' => 'simple']]], + ]); + + self::assertSame( + 'src_a', + $dataSource['data']['items'][0]['salable_quantity'][0]['sources'][0]['source_code'] + ); + } + + public function testDecodesTheSkuBeforeHandingTheRowToTheBreakdown(): void + { + $this->isSourceItemManagementAllowedForProductTypeMock->method('execute')->willReturn(true); + $this->getAssignedStockIdsBySkuMock->method('execute')->willReturn([2]); + $this->getSalableQuantityDataBySkuMock->method('execute')->willReturn([]); + + $brokenDown = $this->createMock(AddSourceSalableQuantityBreakdown::class); + $brokenDown->expects(self::once()) + ->method('execute') + ->with(['sku&1' => []]) + ->willReturnArgument(0); + $column = new SalableQuantity( + $this->contextMock, + $this->uiComponentFactoryMock, + $this->isSourceItemManagementAllowedForProductTypeMock, + $this->getSalableQuantityDataBySkuMock, + $this->getAssignedStockIdsBySkuMock, + $brokenDown, + 2 + ); + + $column->prepareDataSource([ + 'data' => ['totalRecords' => 1, 'items' => [['sku' => 'sku&1', 'type_id' => 'simple']]], + ]); + } } diff --git a/InventorySalesAdminUi/Test/Unit/Ui/DataProvider/Product/Form/Modifier/SalableQuantityTest.php b/InventorySalesAdminUi/Test/Unit/Ui/DataProvider/Product/Form/Modifier/SalableQuantityTest.php new file mode 100644 index 00000000000..9cf8c2138ed --- /dev/null +++ b/InventorySalesAdminUi/Test/Unit/Ui/DataProvider/Product/Form/Modifier/SalableQuantityTest.php @@ -0,0 +1,110 @@ +isSourceItemManagementAllowed = $this->createMock( + IsSourceItemManagementAllowedForProductTypeInterface::class + ); + $this->getSalableQuantityDataBySku = $this->createMock(GetSalableQuantityDataBySku::class); + $this->addSourceSalableQuantityBreakdown = $this->createMock(AddSourceSalableQuantityBreakdown::class); + + $this->product = $this->createMock(Product::class); + $this->product->method('getId')->willReturn(42); + $this->product->method('getSku')->willReturn('sku-1'); + $this->product->method('getTypeId')->willReturn('simple'); + + $locator = $this->createMock(LocatorInterface::class); + $locator->method('getProduct')->willReturn($this->product); + + $this->modifier = new SalableQuantity( + $this->isSourceItemManagementAllowed, + $locator, + $this->getSalableQuantityDataBySku, + $this->addSourceSalableQuantityBreakdown + ); + } + + public function testReportsTheSalableQuantityBrokenDownBySource(): void + { + $this->isSourceItemManagementAllowed->method('execute')->willReturn(true); + $stockEntries = [['stock_id' => 2, 'stock_name' => 'EU Stock', 'qty' => 15.0, 'manage_stock' => true]]; + $brokenDown = [['stock_id' => 2, 'stock_name' => 'EU Stock', 'qty' => 15.0, 'manage_stock' => true, + 'sources' => [['source_code' => 'src_a', 'salable' => 3.0]], 'source_reservations_enabled' => true]]; + + $this->getSalableQuantityDataBySku->method('execute')->with('sku-1')->willReturn($stockEntries); + $this->addSourceSalableQuantityBreakdown->expects(self::once()) + ->method('execute') + ->with(['sku-1' => $stockEntries]) + ->willReturn(['sku-1' => $brokenDown]); + + self::assertSame($brokenDown, $this->modifier->modifyData([])[42]['salable_quantity']); + } + + public function testLeavesTheDataUntouchedForATypeWithoutSourceItemManagement(): void + { + $this->isSourceItemManagementAllowed->method('execute')->willReturn(false); + $this->getSalableQuantityDataBySku->expects(self::never())->method('execute'); + $this->addSourceSalableQuantityBreakdown->expects(self::never())->method('execute'); + + self::assertSame([], $this->modifier->modifyData([])); + } + + public function testLeavesTheMetaUntouchedForATypeWithoutSourceItemManagement(): void + { + $this->isSourceItemManagementAllowed->method('execute')->willReturn(false); + + self::assertSame([], $this->modifier->modifyMeta([])); + } + + public function testMakesTheFieldsetVisibleForAStockableProduct(): void + { + $this->isSourceItemManagementAllowed->method('execute')->willReturn(true); + + $meta = $this->modifier->modifyMeta([]); + + self::assertSame(1, $meta['salable_quantity']['arguments']['data']['config']['visible']); + } +} diff --git a/InventorySalesAdminUi/Ui/Component/Listing/Column/SalableQuantity.php b/InventorySalesAdminUi/Ui/Component/Listing/Column/SalableQuantity.php index 53fda13540d..92cfa5c6fae 100644 --- a/InventorySalesAdminUi/Ui/Component/Listing/Column/SalableQuantity.php +++ b/InventorySalesAdminUi/Ui/Component/Listing/Column/SalableQuantity.php @@ -9,8 +9,8 @@ use Magento\Framework\View\Element\UiComponent\ContextInterface; use Magento\Framework\View\Element\UiComponentFactory; +use Magento\InventorySalesAdminUi\Model\AddSourceSalableQuantityBreakdown; use Magento\InventorySalesAdminUi\Model\GetSalableQuantityDataBySku; -use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface; use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; use Magento\InventorySalesAdminUi\Model\ResourceModel\GetAssignedStockIdsBySku; use Magento\Ui\Component\Listing\Columns\Column; @@ -25,11 +25,6 @@ class SalableQuantity extends Column */ private $isSourceItemManagementAllowedForProductType; - /** - * @var IsSingleSourceModeInterface - */ - private $isSingleSourceMode; - /** * @var GetSalableQuantityDataBySku */ @@ -40,6 +35,11 @@ class SalableQuantity extends Column */ private $getAssignedStockIdsBySku; + /** + * @var AddSourceSalableQuantityBreakdown + */ + private $addSourceSalableQuantityBreakdown; + /** * @var int */ @@ -49,9 +49,9 @@ class SalableQuantity extends Column * @param ContextInterface $context * @param UiComponentFactory $uiComponentFactory * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType - * @param IsSingleSourceModeInterface $isSingleSourceMode * @param GetSalableQuantityDataBySku $getSalableQuantityDataBySku * @param GetAssignedStockIdsBySku $getAssignedStockIdsBySku + * @param AddSourceSalableQuantityBreakdown $addSourceSalableQuantityBreakdown * @param int $maximumStocksToShow * @param array $components * @param array $data @@ -60,18 +60,18 @@ public function __construct( ContextInterface $context, UiComponentFactory $uiComponentFactory, IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType, - IsSingleSourceModeInterface $isSingleSourceMode, GetSalableQuantityDataBySku $getSalableQuantityDataBySku, GetAssignedStockIdsBySku $getAssignedStockIdsBySku, + AddSourceSalableQuantityBreakdown $addSourceSalableQuantityBreakdown, int $maximumStocksToShow, array $components = [], array $data = [] ) { parent::__construct($context, $uiComponentFactory, $components, $data); $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType; - $this->isSingleSourceMode = $isSingleSourceMode; $this->getSalableQuantityDataBySku = $getSalableQuantityDataBySku; $this->getAssignedStockIdsBySku = $getAssignedStockIdsBySku; + $this->addSourceSalableQuantityBreakdown = $addSourceSalableQuantityBreakdown; $this->maximumStocksToShow = $maximumStocksToShow; } @@ -87,12 +87,50 @@ public function prepareDataSource(array $dataSource) ? $this->getSalableQuantityItemData($row['sku']) : []; } + unset($row); + + $dataSource['data']['items'] = $this->addSourceBreakdown($dataSource['data']['items']); } - unset($row); return $dataSource; } + /** + * Add the per-source breakdown to the stock entries of every grid row. + * + * The whole page goes through one call, so the breakdown costs a full page the same number of + * queries it costs a single row. + * + * @param array> $items + * @return array> + */ + private function addSourceBreakdown(array $items): array + { + $stockEntriesBySku = []; + foreach ($items as $item) { + $stockEntriesBySku[$this->decodeSku($item['sku'])] = $item['salable_quantity']; + } + + $stockEntriesBySku = $this->addSourceSalableQuantityBreakdown->execute($stockEntriesBySku); + + foreach ($items as $key => $item) { + $items[$key]['salable_quantity'] = $stockEntriesBySku[$this->decodeSku($item['sku'])]; + } + + return $items; + } + + /** + * Decode a SKU coming from the grid data source. + * + * @param string $sku + * @return string + */ + private function decodeSku(string $sku): string + { + return htmlspecialchars_decode($sku, ENT_QUOTES | ENT_SUBSTITUTE); + } + /** * Get salable quantity data for product * @@ -101,7 +139,7 @@ public function prepareDataSource(array $dataSource) */ private function getSalableQuantityItemData(string $sku): array { - $sku = htmlspecialchars_decode($sku, ENT_QUOTES | ENT_SUBSTITUTE); + $sku = $this->decodeSku($sku); $stockIds = $this->getAssignedStockIdsBySku->execute($sku); if (count($stockIds) > $this->maximumStocksToShow) { diff --git a/InventorySalesAdminUi/Ui/DataProvider/Product/Form/Modifier/SalableQuantity.php b/InventorySalesAdminUi/Ui/DataProvider/Product/Form/Modifier/SalableQuantity.php index fd68398f9d2..c30f701a5e1 100644 --- a/InventorySalesAdminUi/Ui/DataProvider/Product/Form/Modifier/SalableQuantity.php +++ b/InventorySalesAdminUi/Ui/DataProvider/Product/Form/Modifier/SalableQuantity.php @@ -9,8 +9,8 @@ use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier; use Magento\Catalog\Model\Locator\LocatorInterface; +use Magento\InventorySalesAdminUi\Model\AddSourceSalableQuantityBreakdown; use Magento\InventorySalesAdminUi\Model\GetSalableQuantityDataBySku; -use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface; use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; /** @@ -29,31 +29,31 @@ class SalableQuantity extends AbstractModifier private $locator; /** - * @var IsSingleSourceModeInterface + * @var GetSalableQuantityDataBySku */ - private $isSingleSourceMode; + private $getSalableQuantityDataBySku; /** - * @var GetSalableQuantityDataBySku + * @var AddSourceSalableQuantityBreakdown */ - private $getSalableQuantityDataBySku; + private $addSourceSalableQuantityBreakdown; /** * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType * @param LocatorInterface $locator - * @param IsSingleSourceModeInterface $isSingleSourceMode * @param GetSalableQuantityDataBySku $getSalableQuantityDataBySku + * @param AddSourceSalableQuantityBreakdown $addSourceSalableQuantityBreakdown */ public function __construct( IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType, LocatorInterface $locator, - IsSingleSourceModeInterface $isSingleSourceMode, - GetSalableQuantityDataBySku $getSalableQuantityDataBySku + GetSalableQuantityDataBySku $getSalableQuantityDataBySku, + AddSourceSalableQuantityBreakdown $addSourceSalableQuantityBreakdown ) { $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType; $this->locator = $locator; - $this->isSingleSourceMode = $isSingleSourceMode; $this->getSalableQuantityDataBySku = $getSalableQuantityDataBySku; + $this->addSourceSalableQuantityBreakdown = $addSourceSalableQuantityBreakdown; } /** @@ -69,7 +69,11 @@ public function modifyData(array $data) return $data; } - $data[$product->getId()]['salable_quantity'] = $this->getSalableQuantityDataBySku->execute($product->getSku()); + $sku = (string) $product->getSku(); + $stockEntries = $this->getSalableQuantityDataBySku->execute($sku); + $data[$product->getId()]['salable_quantity'] = + $this->addSourceSalableQuantityBreakdown->execute([$sku => $stockEntries])[$sku]; + return $data; } diff --git a/InventorySalesAdminUi/composer.json b/InventorySalesAdminUi/composer.json index 09caed886e8..eb4c1d2a093 100644 --- a/InventorySalesAdminUi/composer.json +++ b/InventorySalesAdminUi/composer.json @@ -15,6 +15,8 @@ "magento/module-inventory-admin-ui": "1.2.*", "magento/module-inventory-api": "1.2.*", "magento/module-inventory-catalog-api": "1.3.*", + "magento/module-inventory-reservations-api": "1.2.*", + "magento/module-inventory-sales": "1.3.*", "magento/module-inventory-sales-api": "1.2.*", "magento/module-inventory-configuration-api": "1.2.*", "magento/module-catalog": "*", diff --git a/InventorySalesAdminUi/i18n/en_US.csv b/InventorySalesAdminUi/i18n/en_US.csv index 20e6bcf8ace..ddb90f65fad 100644 --- a/InventorySalesAdminUi/i18n/en_US.csv +++ b/InventorySalesAdminUi/i18n/en_US.csv @@ -4,4 +4,12 @@ Websites,Websites "Product Salable Quantity","Product Salable Quantity" "Salable Quantity","Salable Quantity" -"Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty)", "Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty)" +"Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty). The breakdown reports, for each source of the stock, the quantity on hand, its source reservation balance and the salable quantity they add up to.","Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty). The breakdown reports, for each source of the stock, the quantity on hand, its source reservation balance and the salable quantity they add up to." +Source,Source +"On Hand","On Hand" +Reservations,Reservations +Salable,Salable +Disabled,Disabled +"Out of Stock","Out of Stock" +"Show sources","Show sources" +"Hide sources","Hide sources" diff --git a/InventorySalesAdminUi/view/adminhtml/ui_component/product_form.xml b/InventorySalesAdminUi/view/adminhtml/ui_component/product_form.xml index 6705b1148e8..f28d74db7cc 100644 --- a/InventorySalesAdminUi/view/adminhtml/ui_component/product_form.xml +++ b/InventorySalesAdminUi/view/adminhtml/ui_component/product_form.xml @@ -10,7 +10,7 @@ - Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty). + Aggregated inventory available to purchase for a stock. The amount aggregates assigned source's Quantity subtracting the Out-of-Stock Threshold (or MinQty). The breakdown reports, for each source of the stock, the quantity on hand, its source reservation balance and the salable quantity they add up to. diff --git a/InventorySalesAdminUi/view/adminhtml/web/css/source/_module.less b/InventorySalesAdminUi/view/adminhtml/web/css/source/_module.less index cf3de4c485b..0ed4219b7cf 100644 --- a/InventorySalesAdminUi/view/adminhtml/web/css/source/_module.less +++ b/InventorySalesAdminUi/view/adminhtml/web/css/source/_module.less @@ -16,3 +16,67 @@ } margin-top: 0; } + +// +// Per-source salable quantity breakdown +// --------------------------------------------- + +.salable-qty-stock { + + .salable-qty-stock { + margin-top: 2rem; + } +} + +.salable-qty-number { + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.salable-qty-muted { + color: @color-gray65; +} + +.salable-qty-flag { + color: @color-gray65; + font-size: 1.1rem; + font-weight: 600; + letter-spacing: .04em; + margin-left: .6rem; + text-transform: uppercase; + white-space: nowrap; +} + +.salable-qty-total-row { + td { + background-color: @color-white; + border-top: .1rem solid @color-lighter-grayish; + font-weight: 600; + } +} + +.salable-qty-total { + font-size: 1.5rem; +} + +.salable-qty-sources-cell.admin__dynamic-rows { + margin-top: .5rem; + + td, + th { + padding: .5rem .6rem; + } +} + +.salable-qty-toggle { + background: none; + border: 0; + color: @color-phoenix; + cursor: pointer; + padding: .3rem 0 0; + text-decoration: underline; + + &:hover { + color: @color-phoenix-dark; + } +} diff --git a/InventorySalesAdminUi/view/adminhtml/web/js/product/grid/cell/salable-quantity.js b/InventorySalesAdminUi/view/adminhtml/web/js/product/grid/cell/salable-quantity.js index f3fa4ec4fea..0c841237cdf 100644 --- a/InventorySalesAdminUi/view/adminhtml/web/js/product/grid/cell/salable-quantity.js +++ b/InventorySalesAdminUi/view/adminhtml/web/js/product/grid/cell/salable-quantity.js @@ -3,8 +3,9 @@ * See COPYING.txt for license details. */ define([ + 'ko', 'Magento_Ui/js/grid/columns/column' -], function (Column) { +], function (ko, Column) { 'use strict'; return Column.extend({ @@ -12,6 +13,18 @@ define([ bodyTmpl: 'Magento_InventorySalesAdminUi/product/grid/cell/salable-quantity.html' }, + /** + * Initialize the per-row expansion state. + * + * @returns {Object} Chainable + */ + initialize: function () { + this._super(); + this.expandedRows = ko.observable({}); + + return this; + }, + /** * Get salable quantity data (stock name and salable qty) * @@ -20,6 +33,51 @@ define([ */ getSalableQuantityData: function (record) { return record[this.index] ? record[this.index] : []; + }, + + /** + * Check whether the record carries a per-source breakdown to expand. + * + * @param {Object} record - Record object + * @returns {Boolean} Result + */ + hasSourceBreakdown: function (record) { + return this.getSalableQuantityData(record).some(function (stock) { + return Boolean(stock.sources && stock.sources.length); + }); + }, + + /** + * Check whether the breakdown of the record is currently expanded. + * + * @param {Object} record - Record object + * @returns {Boolean} Result + */ + isRowExpanded: function (record) { + return Boolean(this.expandedRows()[record._rowIndex]); + }, + + /** + * Toggle the breakdown of the record. + * + * The event must stop here: the grid row itself listens for clicks to open the product, + * so letting it bubble would navigate away instead of expanding the breakdown. + * + * @param {Object} record - Record object + * @param {Object} event - Click event + * @returns {Boolean} Always false, so knockout suppresses the default action + */ + toggleRow: function (record, event) { + var expanded = Object.assign({}, this.expandedRows()); + + expanded[record._rowIndex] = !expanded[record._rowIndex]; + this.expandedRows(expanded); + + if (event) { + event.stopPropagation(); + } + + return false; } }); }); diff --git a/InventorySalesAdminUi/view/adminhtml/web/template/product/form/salable-quantity.html b/InventorySalesAdminUi/view/adminhtml/web/template/product/form/salable-quantity.html index f95ad292f43..c22fc4dda26 100644 --- a/InventorySalesAdminUi/view/adminhtml/web/template/product/form/salable-quantity.html +++ b/InventorySalesAdminUi/view/adminhtml/web/template/product/form/salable-quantity.html @@ -6,12 +6,73 @@ -->
-
-
- +
+ +
+ : - +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+
+
+
+
diff --git a/InventorySalesAdminUi/view/adminhtml/web/template/product/grid/cell/salable-quantity.html b/InventorySalesAdminUi/view/adminhtml/web/template/product/grid/cell/salable-quantity.html index ca8e2593182..6e9a9f698bb 100644 --- a/InventorySalesAdminUi/view/adminhtml/web/template/product/grid/cell/salable-quantity.html +++ b/InventorySalesAdminUi/view/adminhtml/web/template/product/grid/cell/salable-quantity.html @@ -16,7 +16,62 @@ : + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+
+ + + +
From 093f37448760f368ba60cf7bbb3544ac59af35a0 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Mon, 3 Aug 2026 07:44:00 -0400 Subject: [PATCH 3/3] docs(dist): note the per-source salable quantity breakdown in the admin --- DISTRIBUTION.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 35a73d83666..0cf5ffa23e3 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -70,6 +70,22 @@ This mirrors the target Magento version and does not collide with Adobe's own - Concurrent orders on different stocks sharing a source are not serialized against each other (the place-order lock is per stock); totals per stock are always preserved. +- **Salable quantity broken down by source**: the *Product Salable Quantity* + section of the product form and the *Salable Quantity* column of the product + grid report, for every source of a stock, the quantity on hand, that source's + reservation balance and the salable quantity they add up to. The aggregate + Magento already showed becomes the total of that breakdown, so a salable + quantity lower than the quantity on hand can be traced to the source holding + the difference. Sources that contribute nothing are still listed and labelled + — a disabled source, or a source item set out of stock — so a zero reads as a + reason rather than a defect. Notes: + - Single-source mode is untouched: with one source the breakdown would only + repeat the aggregate, so the section renders exactly as before. + - Without source-level reservations the breakdown degrades to the quantity on + hand, since reservations are then held per stock and cannot be attributed + to a source. + - The grid resolves a whole page in one pair of queries and keeps the + breakdown collapsed until it is expanded, which costs no further request. - **Storefront stock visualizer** (opt-in, default off): a product-page *Availability* panel driven by MSI, shipped as the additive `Magento_InventoryStockVisualizer` module (no core module is replaced). The