diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 05949952b38d..633f890f401a 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 diff --git a/InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php b/InventorySales/Model/ResourceModel/SourceReservation/GetSourceItemDataBySkusAndSources.php new file mode 100644 index 000000000000..75b4b53b6ae8 --- /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 000000000000..2f569535c7f0 --- /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 000000000000..13241b3beb1c --- /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]; + } +} diff --git a/InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php b/InventorySalesAdminUi/Model/AddSourceSalableQuantityBreakdown.php new file mode 100644 index 000000000000..a49ac1467d0f --- /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 000000000000..b02f73657fae --- /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 000000000000..1774b59e8db4 --- /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 000000000000..d9b56594c514 --- /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 9b9f88c05acf..9b0c169f0196 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 000000000000..9cf8c2138ed1 --- /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 e9e35347c986..0273f6f07795 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 3b46cdb0391c..3f36c0f36068 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 769952b8ebea..3c5abcfca3ec 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": "104.0.*", diff --git a/InventorySalesAdminUi/i18n/en_US.csv b/InventorySalesAdminUi/i18n/en_US.csv index 20e6bcf8acef..ddb90f65fadc 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 27bcca2c6a12..609a65dfcc4d 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 2a4e0e16fbb2..06742ebc5dc7 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 62a5e68286c6..d52239518d89 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 @@ * All Rights Reserved. */ define([ + 'ko', 'Magento_Ui/js/grid/columns/column' -], function (Column) { +], function (ko, Column) { 'use strict'; //eslint-disable-line 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 d397f435d00e..1cda541617d5 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 6fa13766e776..d82e9bb85a25 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 @@ : + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+
+ + + +