diff --git a/Inventory/Test/Mftf/Test/AdminCanSetOnlyXLeftTresholdForVirtualProductWithDefaultSourceTest.xml b/Inventory/Test/Mftf/Test/AdminCanSetOnlyXLeftTresholdForVirtualProductWithDefaultSourceTest.xml index 0ff74c9ae70c..864bfd5202c2 100644 --- a/Inventory/Test/Mftf/Test/AdminCanSetOnlyXLeftTresholdForVirtualProductWithDefaultSourceTest.xml +++ b/Inventory/Test/Mftf/Test/AdminCanSetOnlyXLeftTresholdForVirtualProductWithDefaultSourceTest.xml @@ -64,6 +64,7 @@ + diff --git a/Inventory/Test/Mftf/Test/AdminCreateVirtualProductWithDefaultSourceTest.xml b/Inventory/Test/Mftf/Test/AdminCreateVirtualProductWithDefaultSourceTest.xml index d03e88037a5a..fdd548945105 100644 --- a/Inventory/Test/Mftf/Test/AdminCreateVirtualProductWithDefaultSourceTest.xml +++ b/Inventory/Test/Mftf/Test/AdminCreateVirtualProductWithDefaultSourceTest.xml @@ -58,6 +58,7 @@ + diff --git a/InventoryAdminUi/Test/Mftf/ActionGroup/AdminGoToProductGridFilterResultsByInputActionGroup.xml b/InventoryAdminUi/Test/Mftf/ActionGroup/AdminGoToProductGridFilterResultsByInputActionGroup.xml index f378e73d9b68..a6bf9afb4c52 100644 --- a/InventoryAdminUi/Test/Mftf/ActionGroup/AdminGoToProductGridFilterResultsByInputActionGroup.xml +++ b/InventoryAdminUi/Test/Mftf/ActionGroup/AdminGoToProductGridFilterResultsByInputActionGroup.xml @@ -53,6 +53,7 @@ + diff --git a/InventoryAdminUi/Test/Mftf/Data/MsiProductData.xml b/InventoryAdminUi/Test/Mftf/Data/MsiProductData.xml index dd342c1b585e..026c2cef478d 100644 --- a/InventoryAdminUi/Test/Mftf/Data/MsiProductData.xml +++ b/InventoryAdminUi/Test/Mftf/Data/MsiProductData.xml @@ -53,4 +53,15 @@ 4 4 + + Downloadable MSI Product + Downloadable-MSI-Product- + downloadable + 100 + 100 + Downloadable-MSI-Product- + 4 + 4 + CustomAttributeCategoryIds + diff --git a/InventoryAdminUi/Test/Mftf/Suite/msi-suite.xml b/InventoryAdminUi/Test/Mftf/Suite/msi-suite.xml index a8eb4b6ba8df..a8e643ce6728 100644 --- a/InventoryAdminUi/Test/Mftf/Suite/msi-suite.xml +++ b/InventoryAdminUi/Test/Mftf/Suite/msi-suite.xml @@ -10,8 +10,6 @@ xsi:noNamespaceSchemaLocation="../../../../../../../vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Suite/etc/suiteSchema.xsd"> - - @@ -24,17 +22,14 @@ - - - - + @@ -44,17 +39,14 @@ - - - - + @@ -65,9 +57,7 @@ - - diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminConfigurableProductDisabledManageStockOnCustomStockTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminConfigurableProductDisabledManageStockOnCustomStockTest.xml index c8a1c73b8c9f..b82eda5de010 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminConfigurableProductDisabledManageStockOnCustomStockTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminConfigurableProductDisabledManageStockOnCustomStockTest.xml @@ -45,6 +45,7 @@ + diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreateDownloadableProductWithDefaultSourceTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreateDownloadableProductWithDefaultSourceTest.xml index 0499f40fd1a9..e926e3fb5e60 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminCreateDownloadableProductWithDefaultSourceTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreateDownloadableProductWithDefaultSourceTest.xml @@ -61,7 +61,8 @@ - + + diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreateShipmentForWholeOrderWithSimpleProductFromCustomSourceTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreateShipmentForWholeOrderWithSimpleProductFromCustomSourceTest.xml index d6adfe141520..d0c8ee76be4d 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminCreateShipmentForWholeOrderWithSimpleProductFromCustomSourceTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreateShipmentForWholeOrderWithSimpleProductFromCustomSourceTest.xml @@ -188,5 +188,4 @@ - - + \ No newline at end of file diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedForWholeOrderWithSimpleProductOnDefaultStockAfterFullInvoiceAndShipmentInAdminTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedForWholeOrderWithSimpleProductOnDefaultStockAfterFullInvoiceAndShipmentInAdminTest.xml new file mode 100644 index 000000000000..d6d393fd4150 --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedForWholeOrderWithSimpleProductOnDefaultStockAfterFullInvoiceAndShipmentInAdminTest.xml @@ -0,0 +1,149 @@ + + + + + + + + + <description value="Credit memo created for whole order with Simple product on Default stock after full invoice and shipment in Admin"/> + <testCaseId value="MSI-1949"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + <createData entity="BasicMsiStock1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <field key="qty">100.00</field> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + </before> + <after> + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createStock" stepKey="deleteStock"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!--Login To storefront as Customer--> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Purchase product once logged in--> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <clearField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clearField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="5" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextCheckout"/> + <waitForPageLoad stepKey="waitForPageLoadCheckout"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!--Admin Area Check ordered quantity--> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearch"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 5" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin Area Process Shipping--> + <comment userInput="Admin - Ship order" stepKey="AdminShipOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateShipment"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMask"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchShipping"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMask"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShip"/> + <waitForLoadingMaskToDisappear stepKey="waitForShipLoadingMask"/> + <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" stepKey="submitShipment"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The shipment has been created." stepKey="seeShipmentCreateSuccess"/> + + <!--Admin Area Process Full Invoice--> + <comment userInput="Admin - Process invoice for full order" stepKey="InvoiceFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToProcessInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskInvoice"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumInvoice"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskGridForInvoice"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowInvoice"/> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForPageLoad time="60" stepKey="waitForPageLoadSubmitInvoice"/> + <scrollToTopOfPage stepKey="scrollToTopMessage"/> + <waitForPageLoad stepKey="waitForPageLoadSuccessMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="checkSuccessMessage"/> + + + <!--Admin Area Create Full Credit Memo--> + <comment userInput="Admin - Create credit memo for full order" stepKey="AdminCreateCreditMemoFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskCreditMemo"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumCreditMemo"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskCreditMemo"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowCreditMemo"/> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <click selector="{{AdminCreditMemoItemsSection.itemReturnToStock('1')}}" stepKey="returnToStockCheckbox"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickSubmit"/> + + + <!--Admin Area Check quantities after Credit Memo--> + <comment userInput="Admin - Check Source quantity and salable quantity after credit memo" stepKey="AdminCheckQuantityAfterCreditMemo"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterCreditMemo"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterCreditMemo"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterCreditMemo"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="100" stepKey="checkSalableQtyAfterCreditMemo"/> + </test> +</tests> \ No newline at end of file diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithFullRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithFullRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml new file mode 100644 index 000000000000..d688873741b3 --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithFullRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml @@ -0,0 +1,160 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CreditMemoCreatedWithFullRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipmentTest"> + <annotations> + <stories value="MSI Credit Memo"/> + <title value="Credit Memo created with full refund with Simple product on Default stock after full invoice and partial shipment"/> + <description value="Credit Memo created with full refund with Simple product on Default stock after full invoice and partial shipment"/> + <testCaseId value="MSI-1974"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + <createData entity="BasicMsiStock1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <field key="qty">100.00</field> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + </before> + <after> + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createStock" stepKey="deleteStock"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!--Login To storefront as Customer--> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Purchase product once logged in--> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <clearField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clearField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="5" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextCheckout"/> + <waitForPageLoad stepKey="waitForPageLoadCheckout"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!--Admin Area Check ordered quantity--> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearch1"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 5" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin Area Process Full Invoice--> + <comment userInput="Admin - Process invoice for full order" stepKey="InvoiceFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToProcessInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskInvoice"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumInvoice"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskGridForInvoice"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowInvoice"/> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForPageLoad time="60" stepKey="waitForPageLoadSubmitInvoice"/> + <scrollToTopOfPage stepKey="scrollToTopMessage"/> + <waitForPageLoad stepKey="waitForPageLoadSuccessMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="checkSuccessMessage"/> + + <!--Admin Area Process Partial Shipping--> + <comment userInput="Admin - Ship partial order" stepKey="AdminShipPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateShipment"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMask"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchShipping"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMask"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShip"/> + <waitForLoadingMaskToDisappear stepKey="waitForShipLoadingMask"/> + <fillField selector="{{AdminShipmentItemsSection.itemQtyToShip('1')}}" userInput="3" stepKey="shipPartialQuantity3"/> + <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" stepKey="submitShipment"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The shipment has been created." stepKey="seeShipmentCreateSuccess"/> + + <!--Admin Area Check source quantity and salable quantity after partial shipment--> + <comment userInput="Admin - Check Source quantity and salable quantity after partial shipment" stepKey="AdminCheckQuantityAfterPartialShipment"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPartialShipment"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPartialShipment"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="97" stepKey="checkProductSourceQtyAfterPartialShipment"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterPartialShipment"/> + + <!--Admin Area Create Full Credit Memo--> + <comment userInput="Admin - Create credit memo for full order" stepKey="AdminCreateCreditMemoFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskCreditMemo"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumCreditMemo"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskCreditMemo"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowCreditMemo"/> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <click selector="{{AdminCreditMemoItemsSection.itemReturnToStock('1')}}" stepKey="returnToStockCheckbox"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickSubmit"/> + + + <!--Admin Area Check quantities after Credit Memo--> + <comment userInput="Admin - Check Source quantity and salable quantity after credit memo" stepKey="AdminCheckQuantityAfterCreditMemo"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterCreditMemo"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterCreditMemo"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterCreditMemo"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="100" stepKey="checkSalableQtyAfterCreditMemo"/> + + </test> +</tests> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundForOrderWithSimpleProductOnDefaultStockAfterFullInvoiceInAdminTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundForOrderWithSimpleProductOnDefaultStockAfterFullInvoiceInAdminTest.xml new file mode 100644 index 000000000000..68d56bbc7813 --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundForOrderWithSimpleProductOnDefaultStockAfterFullInvoiceInAdminTest.xml @@ -0,0 +1,154 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreditMemoCreatedWithPartialRefundForOrderWithSimpleProductOnDefaultStockAfterFullInvoiceInAdminTest"> + <annotations> + <stories value="MSI Credit Memo"/> + <title value="Credit memo created with partial Refund for order with Simple product on Default stock after full invoice in Admin"/> + <description value="Credit memo created with partial Refund for order with Simple product on Default stock after full invoice in Admin"/> + <testCaseId value="MSI-1973"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <createData entity="BasicMsiStock1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + + <comment userInput="Assign main website to default stock" stepKey="assignChannelToStockComment"/> + <amOnPage url="{{AdminManageStockPage.url}}" stepKey="navigateToStockListPageToAssignMainWebsiteToDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForStockListPageLoad"/> + <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchDefaultStockByNameForAssignMainWebsiteChannel"> + <argument name="keyword" value="_defaultStock.name"/> + </actionGroup> + <click selector="{{AdminGridRow.editByValue(_defaultStock.name)}}" stepKey="clickEditDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForDefaultStockPageLoaded"/> + <selectOption selector="{{AdminEditStockSalesChannelsSection.websites}}" userInput="Main Website" stepKey="selectDefaultWebsiteAsSalesChannelForDefaultStock"/> + <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> + + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <field key="qty">100.00</field> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + + </before> + <after> + <actionGroup ref="logout" stepKey="logoutOfAdmin1"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createStock" stepKey="deleteStock"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!--Login To storefront as Customer--> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Purchase product once logged in--> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <clearField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clearField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="5" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextPaymentPage"/> + <waitForPageLoad stepKey="waitForPageLoadCheckoutSelectPayment"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!--Admin Area Check ordered quantity--> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCheckOrderAfterCustomerSubmits"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 5" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin Area Process Full Invoice--> + <comment userInput="Admin - Process invoice for full order" stepKey="InvoiceFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToProcessInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskInvoice"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumInvoice"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskGridForInvoice"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowInvoice"/> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForPageLoad time="60" stepKey="waitForPageLoadSubmitInvoice"/> + <scrollToTopOfPage stepKey="scrollToTopMessage"/> + <waitForPageLoad stepKey="waitForPageLoadSuccessMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="checkSuccessMessage"/> + + + <!--Admin Area Create Partial Credit Memo--> + <comment userInput="Admin - Create credit memo for one item of invoiced order" stepKey="AdminCreateCreditMemoPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskCreditMemo"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumCreditMemo"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskCreditMemo"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowCreditMemo"/> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <click selector="{{AdminCreditMemoItemsSection.itemReturnToStock('1')}}" stepKey="returnToStockCheckbox"/> + <fillField selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" userInput="1" stepKey="partialRefund"/> + <click selector="{{AdminCreditMemoItemsSection.updateQty}}" stepKey="updateQuantityToRefund"/> + <waitForLoadingMaskToDisappear stepKey="updateQuantityLoadingMask"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickSubmit"/> + + + <!--Admin Area Check quantities after Credit Memo--> + <comment userInput="Admin - Check Source quantity and salable quantity after credit memo" stepKey="AdminCheckQuantityAfterCreditMemo"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterCreditMemo"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterCreditMemo"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterCreditMemo"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="96" stepKey="checkSalableQtyAfterCreditMemo"/> + + </test> +</tests> \ No newline at end of file diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml new file mode 100644 index 000000000000..b748c8693941 --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment.xml @@ -0,0 +1,176 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterFullInvoiceAndPartialShipment"> + <annotations> + <stories value="MSI Credit Memo"/> + <title value="Credit Memo created with partial refund with Simple product on Default stock after full invoice and partial shipment"/> + <description value="Credit Memo created with partial refund with Simple product on Default stock after full invoice and partial shipment"/> + <testCaseId value="MSI-1976"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + <createData entity="BasicMsiStock1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + + <comment userInput="Assign main website to default stock" stepKey="assignChannelToStockComment"/> + <amOnPage url="{{AdminManageStockPage.url}}" stepKey="navigateToStockListPageToAssignMainWebsiteToDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForStockListPageLoad"/> + <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchDefaultStockByNameForAssignMainWebsiteChannel"> + <argument name="keyword" value="_defaultStock.name"/> + </actionGroup> + <click selector="{{AdminGridRow.editByValue(_defaultStock.name)}}" stepKey="clickEditDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForDefaultStockPageLoaded"/> + <selectOption selector="{{AdminEditStockSalesChannelsSection.websites}}" userInput="Main Website" stepKey="selectDefaultWebsiteAsSalesChannelForDefaultStock"/> + <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> + + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <field key="qty">100.00</field> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + </before> + <after> + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createStock" stepKey="deleteStock"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!--Login To storefront as Customer--> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Purchase product once logged in--> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <clearField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clearField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="5" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextCheckout"/> + <waitForPageLoad stepKey="waitForPageLoadCheckout"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!--Admin Area Check ordered quantity--> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearch"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 5" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="995" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin Area Process Full Invoice--> + <comment userInput="Admin - Process invoice for full order" stepKey="InvoiceFullOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToProcessInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskInvoice"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumInvoice"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskGridForInvoice"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowInvoice"/> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForPageLoad time="60" stepKey="waitForPageLoadSubmitInvoice"/> + <scrollToTopOfPage stepKey="scrollToTopMessage"/> + <waitForPageLoad stepKey="waitForPageLoadSuccessMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="checkSuccessMessage"/> + + <!--Admin Area Process Partial Shipping--> + <comment userInput="Admin - Ship partial order" stepKey="AdminShipPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateShipment"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMask"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchShipping"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMask"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShip"/> + <waitForLoadingMaskToDisappear stepKey="waitForShipLoadingMask"/> + <fillField selector="{{AdminShipmentItemsSection.itemQtyToShip('1')}}" userInput="3" stepKey="shipPartialQuantity3"/> + <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" stepKey="submitShipment"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The shipment has been created." stepKey="seeShipmentCreateSuccess"/> + + <!--Admin Area Check source quantity and salable quantity after partial shipment--> + <comment userInput="Admin - Check Source quantity and salable quantity after partial shipment" stepKey="AdminCheckQuantityAfterPartialShipment"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPartialShipment"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPartialShipment"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="97" stepKey="checkProductSourceQtyAfterPartialShipment"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterPartialShipment"/> + + <!--Admin Area Create Partial Credit Memo--> + <comment userInput="Admin - Create credit memo for one item of invoiced order" stepKey="AdminCreateCreditMemoPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskCreditMemo"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumCreditMemo"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskCreditMemo"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowCreditMemo"/> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <click selector="{{AdminCreditMemoItemsSection.itemReturnToStock('1')}}" stepKey="returnToStockCheckbox"/> + <fillField selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" userInput="1" stepKey="partialRefund"/> + <click selector="{{AdminCreditMemoItemsSection.updateQty}}" stepKey="updateQuantityToRefund"/> + <waitForLoadingMaskToDisappear stepKey="updateQuantityLoadingMask"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickSubmit"/> + + + <!--Admin Area Check quantities after Credit Memo--> + <comment userInput="Admin - Check Source quantity and salable quantity after credit memo" stepKey="AdminCheckQuantityAfterCreditMemo"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterCreditMemo"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterCreditMemo"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="97" stepKey="checkProductSourceQtyAfterCreditMemo"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="96" stepKey="checkSalableQtyAfterCreditMemo"/> + + </test> +</tests> \ No newline at end of file diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterPartialInvoiceAndPartialShipmentTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterPartialInvoiceAndPartialShipmentTest.xml new file mode 100644 index 000000000000..3d0ff5145dcb --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterPartialInvoiceAndPartialShipmentTest.xml @@ -0,0 +1,179 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreditMemoCreatedWithPartialRefundWithSimpleProductOnDefaultStockAfterPartialInvoiceAndPartialShipment"> + <annotations> + <stories value="MSI Credit Memo"/> + <title value="Credit Memo created with partial refund with Simple product on Default stock after partial invoice and partial shipment"/> + <description value="Credit Memo created with partial refund with Simple product on Default stock after partial invoice and partial shipment"/> + <testCaseId value="MSI-1977"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <createData entity="BasicMsiStock1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + + <comment userInput="Assign main website to default stock" stepKey="assignChannelToStockComment"/> + <amOnPage url="{{AdminManageStockPage.url}}" stepKey="navigateToStockListPageToAssignMainWebsiteToDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForStockListPageLoad"/> + <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchDefaultStockByNameForAssignMainWebsiteChannel"> + <argument name="keyword" value="_defaultStock.name"/> + </actionGroup> + <click selector="{{AdminGridRow.editByValue(_defaultStock.name)}}" stepKey="clickEditDefaultStock"/> + <waitForPageLoad time="30" stepKey="waitForDefaultStockPageLoaded"/> + <selectOption selector="{{AdminEditStockSalesChannelsSection.websites}}" userInput="Main Website" stepKey="selectDefaultWebsiteAsSalesChannelForDefaultStock"/> + <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> + + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + </before> + <after> + <actionGroup ref="logout" stepKey="logoutOfAdmin1"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createStock" stepKey="deleteStock"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!--Login To storefront as Customer--> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Purchase product once logged in--> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <clearField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clearField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="10" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextPaymentPage"/> + <waitForPageLoad stepKey="waitForPageLoadCheckoutSelectPayment"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!--Admin Area Check ordered quantity--> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCheckOrderAfterCustomerSubmits"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 10" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="90" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin Area Process Partial Invoice--> + <comment userInput="Admin - Process partial invoice" stepKey="InvoicePartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToProcessInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskInvoice"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumInvoice"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchInvoice"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskGridForInvoice"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowInvoice"/> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> + <scrollTo selector="{{AdminInvoiceItemsSection.updateQty}}" stepKey="scrollToQty"/> + <fillField selector="{{AdminInvoiceItemsSection.itemQtyToInvoice('1')}}" userInput="7" stepKey="InvoiceQuantityPartial" /> + <click selector="{{AdminInvoiceItemsSection.updateQty}}" stepKey="clickUpdateQty" /> + <waitForPageLoad stepKey="WaitForInvoiceQtyUpdate"/> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForPageLoad time="60" stepKey="waitForPageLoadSubmitInvoice"/> + <scrollToTopOfPage stepKey="scrollToTopMessage"/> + <waitForPageLoad stepKey="waitForPageLoadSuccessMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="checkSuccessMessage"/> + + <!--Admin Area Process Partial Shipping--> + <comment userInput="Admin - Ship partial order" stepKey="AdminShipPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateShipment"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMask"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchShipping"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMask"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShip"/> + <waitForLoadingMaskToDisappear stepKey="waitForShipLoadingMask"/> + <fillField selector="{{AdminShipmentItemsSection.itemQtyToShip('1')}}" userInput="3" stepKey="shipPartialQuantity3"/> + <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" stepKey="submitShipment"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The shipment has been created." stepKey="seeShipmentCreateSuccess"/> + + <!--Admin Area Check source quantity and salable quantity after partial shipment--> + <comment userInput="Admin - Check Source quantity and salable quantity after partial shipment" stepKey="AdminCheckQuantityAfterPartialShipment"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPartialShipment"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPartialShipment"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="97" stepKey="checkProductSourceQtyAfterPartialShipment"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="90" stepKey="checkSalableQtyAfterPartialShipment"/> + + + <!--Admin Area Create Partial Credit Memo--> + <comment userInput="Admin - Create credit memo for one item of invoiced order" stepKey="AdminCreateCreditMemoPartialOrder"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPageToCreateCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForOrdersPageLoadingMaskCreditMemo"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNumCreditMemo"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCreditMemo"/> + <waitForLoadingMaskToDisappear stepKey="waitForSubmitSearchLoadingMaskCreditMemo"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRowCreditMemo"/> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <click selector="{{AdminCreditMemoItemsSection.itemReturnToStock('1')}}" stepKey="returnToStockCheckbox"/> + <fillField selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" userInput="5" stepKey="partialRefund"/> + <click selector="{{AdminCreditMemoItemsSection.updateQty}}" stepKey="updateQuantityToRefund"/> + <waitForLoadingMaskToDisappear stepKey="updateQuantityLoadingMask"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickSubmit"/> + + + <!--Admin Area Check quantities after Credit Memo--> + <comment userInput="Admin - Check Source quantity and salable quantity after credit memo" stepKey="AdminCheckQuantityAfterCreditMemo"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterCreditMemo"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterCreditMemo"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',_defaultSource.name)}}" userInput="98" stepKey="checkProductSourceQtyAfterCreditMemo"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',_defaultStock.name)}}" userInput="95" stepKey="checkSalableQtyAfterCreditMemo"/> + + </test> +</tests> \ No newline at end of file diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminManageStockOnConfigurationPageTurnedOffForSimpleProductTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminManageStockOnConfigurationPageTurnedOffForSimpleProductTest.xml index 513a5adf555f..a3284467114b 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminManageStockOnConfigurationPageTurnedOffForSimpleProductTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminManageStockOnConfigurationPageTurnedOffForSimpleProductTest.xml @@ -89,7 +89,6 @@ <click selector="{{AdminAssignSourcesSlideOutGridSection.checkboxByCode($$customSource.source[source_code]$$)}}" stepKey="selectCreatedCustomSource"/> <click selector="{{AdminAssignSourcesSlideOutSection.done}}" stepKey="doneSelectSource"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$customSource.source[source_code]$$" stepKey="checkSelectedSourceCodeInProductAssignedSourcesList"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$customSource.source[name]$$" stepKey="checkSelectedSourceNameInProductAssignedSourcesList"/> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillSourceQtyField"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminMassActionTransferInventoryToSourceForDifferentTypeOfProductsTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminMassActionTransferInventoryToSourceForDifferentTypeOfProductsTest.xml index a24a312df07a..ef951e10f6f8 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminMassActionTransferInventoryToSourceForDifferentTypeOfProductsTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminMassActionTransferInventoryToSourceForDifferentTypeOfProductsTest.xml @@ -61,6 +61,7 @@ <click selector="{{AdminGridRow.editByValue($$createDownloadableProduct.product[sku]$$)}}" stepKey="clickOnEditDownloadableProductForCheckInStock"/> <comment userInput="Assign category to product." stepKey="assignCategoryComment"/> <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" parameterArray="[$$createCategory.name$$]" requiredAction="true" stepKey="searchAndSelectCategory"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('0')}}" userInput="In Stock" stepKey="selectStockStatus" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="1000" stepKey="fillSourceQuantityField"/> <comment userInput="Add downloadable links to product." stepKey="addDownloadableLinks"/> <click selector="{{AdminProductDownloadableSection.sectionHeader}}" stepKey="openDownloadableSection"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToSimpleProductOnConfigurationAdminPageTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToSimpleProductOnConfigurationAdminPageTest.xml index c22fe6d81c38..94d4f7b843bf 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToSimpleProductOnConfigurationAdminPageTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToSimpleProductOnConfigurationAdminPageTest.xml @@ -100,7 +100,6 @@ <click selector="{{AdminAssignSourcesSlideOutGridSection.checkboxByCode($$customSource.source[source_code]$$)}}" stepKey="assignCustomSourceToProduct"/> <click selector="{{AdminAssignSourcesSlideOutSection.done}}" stepKey="doneWithCustomSourceAssignment"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$customSource.source[source_code]$$" stepKey="checkCustomSourceCode"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$customSource.source[name]$$" stepKey="checkCustomSourceName"/> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillSourceQty"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToVirtualProductOnConfigurationAdminPageTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToVirtualProductOnConfigurationAdminPageTest.xml index 0ef1afbca07f..6fcfe778c4cb 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToVirtualProductOnConfigurationAdminPageTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminNotifyQuantityUseDefaultAppliedToVirtualProductOnConfigurationAdminPageTest.xml @@ -103,7 +103,6 @@ <click selector="{{AdminAssignSourcesSlideOutGridSection.checkboxByCode($$createSource1.source[source_code]$$)}}" stepKey="assignCustomSourceToProduct1"/> <click selector="{{AdminAssignSourcesSlideOutSection.done}}" stepKey="doneWithCustomSourceAssignment1"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createSource1.source[source_code]$$" stepKey="checkCustomSourceCode1"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createSource1.source[name]$$" stepKey="checkCustomSourceName1"/> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{VirtualMsiProduct.quantity}}" stepKey="fillSourceQty1"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminRemoveSourcesAssignedToProductTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminRemoveSourcesAssignedToProductTest.xml index 59a8be4ecba5..94e3b92636a4 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminRemoveSourcesAssignedToProductTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminRemoveSourcesAssignedToProductTest.xml @@ -58,29 +58,22 @@ <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="default" stepKey="seeSourceIdInGrid1"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="Default Source" stepKey="seeSourceNameInGrid1"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[source_code]$$" stepKey="seeSourceIdInGrid2"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[name]$$" stepKey="seeSourceNameInGrid2"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('2')}}" userInput="$$createSource2.source[source_code]$$" stepKey="seeSourceIdInGrid3"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('2')}}" userInput="$$createSource2.source[name]$$" stepKey="seeSourceNameInGrid3"/> <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="clickOnSaveAndContinue1"/> <click selector="{{AdminProductSourcesGrid.rowDelete('1')}}" stepKey="clickOnDelete1"/> - <dontSee selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[source_code]$$" stepKey="dontSeeSourceIdInGrid1"/> <dontSee selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[name]$$" stepKey="dontSeeSourceNameInGrid1"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource2.source[source_code]$$" stepKey="seeSourceIdInGrid5"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource2.source[name]$$" stepKey="seeSourceNameInGrid5"/> <click selector="{{AdminProductSourcesGrid.rowDelete('1')}}" stepKey="clickOnDelete2"/> - <dontSee selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createSource2.source[source_code]$$" stepKey="dontSeeSourceIdInGrid2"/> <dontSee selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createSource2.source[name]$$" stepKey="dontSeeSourceNameInGrid2"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="default" stepKey="seeSourceIdInGrid6"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="Default Source" stepKey="seeSourceNameInGrid6"/> <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="clickOnSaveAndContinue2"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="default" stepKey="seeSourceIdInGrid7"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="Default Source" stepKey="seeSourceNameInGrid7"/> <seeNumberOfElements selector=".data-row" userInput="1" stepKey="seeOneSourceRow1"/> </test> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminSourceForEachQuantityCanBeSetByAdminTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminSourceForEachQuantityCanBeSetByAdminTest.xml index 891a99407ce3..1f5d1abf2629 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminSourceForEachQuantityCanBeSetByAdminTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminSourceForEachQuantityCanBeSetByAdminTest.xml @@ -43,7 +43,6 @@ <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="100" stepKey="fillDefaultQuantityField1"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="{{_defaultSource.source_code}}" stepKey="seeSourceIdInGrid1"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="{{_defaultSource.name}}" stepKey="seeSourceNameInGrid1"/> <seeInField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="100" stepKey="seeSourceNameInGrid2"/> @@ -54,7 +53,6 @@ <fillField selector="{{AdminProductSourcesGrid.rowQty('1')}}" userInput="100" stepKey="fillDefaultQuantityField2"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[source_code]$$" stepKey="seeSourceIdInGrid2"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$createSource1.source[name]$$" stepKey="seeSourceNameInGrid3"/> <seeInField selector="{{AdminProductSourcesGrid.rowQty('1')}}" userInput="100" stepKey="seeSourceNameInGrid4"/> @@ -65,7 +63,6 @@ <fillField selector="{{AdminProductSourcesGrid.rowQty('2')}}" userInput="100" stepKey="fillDefaultQuantityField5"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('2')}}" userInput="$$createSource2.source[source_code]$$" stepKey="seeSourceIdInGrid3"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('2')}}" userInput="$$createSource2.source[name]$$" stepKey="seeSourceNameInGrid6"/> <seeInField selector="{{AdminProductSourcesGrid.rowQty('2')}}" userInput="100" stepKey="seeSourceNameInGrid7"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminSourcePrioritySelectionAlgorithmSimpleProductCustomStockTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminSourcePrioritySelectionAlgorithmSimpleProductCustomStockTest.xml index d3bc06fd5fbc..2dfbcfdda8f9 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminSourcePrioritySelectionAlgorithmSimpleProductCustomStockTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminSourcePrioritySelectionAlgorithmSimpleProductCustomStockTest.xml @@ -134,9 +134,7 @@ <click selector="{{AdminAssignSourcesSlideOutGridSection.checkboxByCode($$highPrioritySource.source[source_code]$$)}}" stepKey="selectHighPrioritySource"/> <click selector="{{AdminAssignSourcesSlideOutSection.done}}" stepKey="doneSelectHighPrioritySource"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$lowPrioritySource.source[source_code]$$" stepKey="checkLowPrioritySourceCode"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$lowPrioritySource.source[name]$$" stepKey="checkLowPrioritySourceName"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$highPrioritySource.source[source_code]$$" stepKey="checkHighPrioritySourceCode"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('1')}}" userInput="$$highPrioritySource.source[name]$$" stepKey="checkHighPrioritySourceName"/> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{SimpleProduct.quantity}}" stepKey="fillSourceQtyFieldForLowPrioritySource"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminUserApplyOnlyXLeftThresholdForSimpleProductOnDefaultSourceTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminUserApplyOnlyXLeftThresholdForSimpleProductOnDefaultSourceTest.xml index 6fdcf6564232..6c692a28d41d 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminUserApplyOnlyXLeftThresholdForSimpleProductOnDefaultSourceTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminUserApplyOnlyXLeftThresholdForSimpleProductOnDefaultSourceTest.xml @@ -35,13 +35,6 @@ <requiredEntity createDataKey="createSource"/> </createData> - <createData entity="SimpleSubCategory" stepKey="createCategory"/> - <createData entity="SimpleProduct" stepKey="createSimpleProduct"> - <field key="price">10.00</field> - <requiredEntity createDataKey="createCategory"/> - </createData> - <createData entity="Msi_US_Customer" stepKey="createCustomer"/> - <actionGroup ref="LoginAsAdmin" stepKey="loginToAdminArea"/> <waitForPageLoad stepKey="waitForDashboardLoad"/> @@ -55,6 +48,13 @@ <waitForPageLoad time="30" stepKey="waitForDefaultStockPageLoaded"/> <selectOption selector="{{AdminEditStockSalesChannelsSection.websites}}" userInput="Main Website" stepKey="selectDefaultWebsiteAsSalesChannelForDefaultStock"/> <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> + + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createSimpleProduct"> + <field key="price">10.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="Msi_US_Customer" stepKey="createCustomer"/> </before> <after> <comment userInput="Disable created source." stepKey="disableCreatedSourceComment"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/AdminUserSetStatusForEachSourceItemTest.xml b/InventoryAdminUi/Test/Mftf/Test/AdminUserSetStatusForEachSourceItemTest.xml index 83fafd56ee12..e1276dd462a4 100644 --- a/InventoryAdminUi/Test/Mftf/Test/AdminUserSetStatusForEachSourceItemTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/AdminUserSetStatusForEachSourceItemTest.xml @@ -83,10 +83,15 @@ <scrollToTopOfPage stepKey="scrollToTopOfPage1"/> <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" parameterArray="[$$simpleCategory1.name$$]" requiredAction="true" stepKey="searchAndSelectCategory1"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('0')}}" userInput="In Stock" stepKey="selectStockStatusSource1" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillCustomSource1QtyField"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('1')}}" userInput="In Stock" stepKey="selectStockStatusSource2" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('1')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillCustomSource2QtyField"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('2')}}" userInput="In Stock" stepKey="selectStockStatusSource3" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('2')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillCustomSource3QtyField"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('3')}}" userInput="In Stock" stepKey="selectStockStatusSource4" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('3')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillCustomSource4QtyField"/> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('4')}}" userInput="In Stock" stepKey="selectStockStatusSource5" /> <fillField selector="{{AdminProductSourcesGrid.rowQty('4')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillCustomSource5QtyField"/> <actionGroup ref="AdminFormSaveAndClose" stepKey="saveAndCloseSimpleProduct"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnCustomStockFromHomepageTest.xml b/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnCustomStockFromHomepageTest.xml index 89b88bb92201..904e069a95f7 100644 --- a/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnCustomStockFromHomepageTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnCustomStockFromHomepageTest.xml @@ -20,104 +20,33 @@ </annotations> <before> + <magentoCLI stepKey="enableGuestCheckoutForDownloadable" command="config:set catalog/downloadable/disable_guest_checkout 0" /> + <magentoCLI stepKey="enableStockManagement" command="config:set cataloginventory/item_options/manage_stock 1"/> <createData entity="FullSource1" stepKey="createSource"/> <createData entity="BasicMsiStockWithMainWebsite1" stepKey="createStock"/> - <createData entity="SimpleSubCategory" stepKey="createCategory"/> - <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> <requiredEntity createDataKey="createStock"/> <requiredEntity createDataKey="createSource"/> </createData> - - <magentoCLI command="indexer:reindex" stepKey="magentoCli"/> - + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="MsiDownloadableProduct" stepKey="downloadableProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="downloadableLink1" stepKey="addDownloadableLink"> + <field key="is_shareable">1</field> + <requiredEntity createDataKey="downloadableProduct"/> + </createData> <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> - - <!-- Create Configurable Product --> - <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToAdminProductGrid"/> - <waitForPageLoad time="30" stepKey="waitForProductGridLoad"/> - <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickOnAddProductToggle"/> - <click selector="{{AdminProductGridActionSection.addTypeProduct('configurable')}}" - stepKey="addConfigurableProduct"/> - <waitForPageLoad time="30" stepKey="waitForConfigurableProductNewPageLoad"/> - - <fillField userInput="{{ConfigurableMsiProduct.name}}" selector="{{AdminProductFormSection.productName}}" - stepKey="fillProductName"/> - <fillField userInput="{{ConfigurableMsiProduct.price}}" selector="{{AdminProductFormSection.productPrice}}" - stepKey="fillProductPrice"/> - <fillField userInput="{{ConfigurableMsiProduct.sku}}" selector="{{AdminProductFormSection.productSku}}" - stepKey="fillProductSku"/> - <fillField userInput="{{ConfigurableMsiProduct.quantity}}" - selector="{{AdminConfigurableProductFormSection.productQuantity}}" - stepKey="fillProductQuantity"/> - <fillField userInput="{{ConfigurableMsiProduct.weight}}" - selector="{{AdminConfigurableProductFormSection.productWeight}}" stepKey="fillProductWeight"/> - - <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" - parameterArray="[$$createCategory.name$$]" stepKey="searchAndSelectCategory"/> - <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" - stepKey="clickOnTheCreateConfigurationsButton"/> - <waitForElementVisible selector="{{AdminConfigurableProductSelectAttributesSlideOut.grid}}" time="30" - stepKey="waitForGridPresents"/> - - <click selector="{{AdminGridRow.checkboxByValue('color')}}" stepKey="selectColorAttribute"/> - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToSecondStep"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="addNewColorWhite"/> - <fillField userInput="{{colorProductAttribute1.name}}" - selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" stepKey="setNameWhite"/> - <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="saveWhiteColor"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="addNewColorRed"/> - <fillField userInput="{{colorProductAttribute2.name}}" - selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" stepKey="setNameRed"/> - <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="saveRedColor"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToThirdStep"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" - stepKey="clickOnApplySingleQuantityToEachSku"/> - - <click selector="{{AdminConfigurableProductAssignSourcesSlideOut.assignSources}}" - stepKey="openSelectSourcesModalWindow"/> - <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" - dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" - stepKey="clearSourcesFilter"/> - <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchCustomByNameForAssignment"> - <argument name="keyword" value="$$createSource.source[name]$$"/> + <amOnPage url="{{AdminProductEditPage.url($$downloadableProduct.id$$)}}" stepKey="openProductEditPage" /> + <actionGroup ref="AssignSourceToProductActionGroup" stepKey="assignCustomSource"> + <argument name="sourceCode" value="$$createSource.source[source_code]$$" /> </actionGroup> - <click selector="{{AdminGridRow.checkboxByValue($$createSource.source[name]$$)}}" - stepKey="selectDefaultSource"/> - <click selector="{{AdminConfigurableProductAssignSourcesSlideOut.done}}" stepKey="doneAssignSources"/> - <fillField selector="{{AdminConfigurableProductAssignSourcesSlideOut.quantityPerSource('0')}}" - userInput="100" stepKey="fillQuantityForCustomSource"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToFourthStep"/> - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" - stepKey="doneGeneratingConfigurableVariations"/> - - <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveConfigurableProduct"/> - <conditionalClick selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" - dependentSelector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" visible="true" - stepKey="confirmDefaultAttributeSetForConfigurableProduct"/> - <seeElement selector="{{AdminProductMessagesSection.successMessage}}" stepKey="checkProductSavedMessage"/> - - <seeNumberOfElements selector="{{AdminProductFormConfigurationsSection.currentVariationsRows}}" - userInput="2" stepKey="checkConfigurableMatrix"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteAttributeVariationName"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" - userInput="{{colorProductAttribute2.name}}" stepKey="checkRedAttributeVariationName"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteAttributeVariationSku"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" - userInput="{{colorProductAttribute2.name}}" stepKey="checkRedAttributeVariationSku"/> - <see selector="{{AdminConfigurableProductFormSection.currentVariationsQuantityCells}}" userInput="100" - stepKey="checkQtyIsCorrectForCustomSource"/> - - <actionGroup ref="AdminFormSaveAndClose" stepKey="saveAndClose"/> + <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="100" stepKey="fillDefaultSourceQtyField"/> + <fillField selector="{{AdminProductSourcesGrid.rowQty('1')}}" userInput="100" stepKey="fillCustomSourceQtyField"/> + <actionGroup ref="AdminFormSaveAndClose" stepKey="saveAndCloseProduct"/> </before> <after> + <magentoCLI stepKey="disableGuestCheckoutForDownloadable" command="config:set catalog/downloadable/disable_guest_checkout 1" /> <!-- Assign Sales Channel to Default Stock --> <amOnPage url="{{AdminManageStockPage.url}}" stepKey="amOnTheStockGridPage"/> <waitForPageLoad time="30" stepKey="waitForStockGridPageLoad"/> @@ -130,45 +59,33 @@ <selectOption selector="{{AdminEditStockSalesChannelsSection.websites}}" userInput="Main Website" stepKey="selectWebsiteAsSalesChannel"/> <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> - + <actionGroup ref="DisableSourceActionGroup" stepKey="disableSource"> <argument name="sourceCode" value="$$createSource.source[source_code]$$"/> </actionGroup> <actionGroup ref="logout" stepKey="logoutOfAdmin"/> <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="downloadableProduct" stepKey="deleteProduct"/> </after> - <actionGroup ref="AssertProductInStorefrontProductPage" stepKey="assertProductInStorefront"> - <argument name="product" value="ConfigurableMsiProduct"/> - </actionGroup> - - <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="navigateToCategory"/> - <waitForPageLoad time="30" stepKey="waitForCategoryPageLoad"/> - <click selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo(ConfigurableMsiProduct.name)}}" - stepKey="openProductPage"/> - <waitForAjaxLoad stepKey="waitForImageLoader"/> - <selectOption selector="{{StorefrontConfigurableProductPage.productAttributeDropDown}}" - userInput="{{colorProductAttribute1.name}}" stepKey="selectWhiteVariation"/> - <seeOptionIsSelected selector="{{StorefrontConfigurableProductPage.productAttributeDropDown}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteVariationIsSelected"/> - <fillField selector="{{StorefrontProductPageSection.qtyInput}}" userInput="5" stepKey="fillQuantity"/> + <amOnPage url="{{StorefrontProductPage.url($$downloadableProduct.custom_attributes[url_key]$$)}}" stepKey="openProductPage"/> + <waitForPageLoad stepKey="waitForImageLoader"/> <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="addToCart"/> - <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" - stepKey="waitForProductAdded"/> + <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" stepKey="waitForProductAdded"/> <!-- Place Order --> <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> <fillField selector="{{CheckoutShippingSection.email}}" userInput="{{MsiCustomer1.email}}" stepKey="enterEmail"/> - <fillField selector="#shipping-new-address-form input[name=firstname]" userInput="{{MsiCustomer1.firstname}}" stepKey="enterFirstName"/> - <fillField selector="#shipping-new-address-form input[name=lastname]" userInput="{{MsiCustomer1.lastname}}" stepKey="enterLastName"/> - <fillField selector="#shipping-new-address-form input[name='street[0]']" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="enterStreet"/> - <fillField selector="#shipping-new-address-form input[name=city]" userInput="{{CustomerAddressSimple.city}}" stepKey="enterCity"/> - <selectOption selector="#shipping-new-address-form select[name=region_id]" userInput="{{CustomerAddressSimple.state}}" stepKey="selectRegion"/> - <fillField selector="#shipping-new-address-form input[name=postcode]" userInput="{{CustomerAddressSimple.postcode}}" stepKey="enterPostcode"/> - <fillField selector="#shipping-new-address-form input[name=telephone]" userInput="{{CustomerAddressSimple.telephone}}" stepKey="enterTelephone"/> - - <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> + <fillField selector="{{CheckoutPaymentSection.guestFirstName}}" userInput="{{MsiCustomer1.firstname}}" stepKey="enterFirstName"/> + <fillField selector="{{CheckoutPaymentSection.guestLastName}}" userInput="{{MsiCustomer1.lastname}}" stepKey="enterLastName"/> + <fillField selector="{{CheckoutPaymentSection.guestStreet}}" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="enterStreet"/> + <fillField selector="{{CheckoutPaymentSection.guestCity}}" userInput="{{CustomerAddressSimple.city}}" stepKey="enterCity"/> + <selectOption selector="{{CheckoutPaymentSection.guestRegion}}" userInput="{{CustomerAddressSimple.state}}" stepKey="selectRegion"/> + <fillField selector="{{CheckoutPaymentSection.guestPostcode}}" userInput="{{CustomerAddressSimple.postcode}}" stepKey="enterPostcode"/> + <fillField selector="{{CheckoutPaymentSection.guestTelephone}}" userInput="{{CustomerAddressSimple.telephone}}" stepKey="enterTelephone"/> + + <click selector="{{CheckoutPaymentSection.update}}" stepKey="clickUpdate"/> <waitForPageLoad stepKey="waitForPageLoad"/> <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> @@ -190,15 +107,13 @@ <actionGroup ref="AdminGoToProductGridFilterResultsByInput" stepKey="goToProductGridFilterResultsByInput"> <argument name="filter_selector" value="AdminProductGridFilterSection.skuFilter"/> - <argument name="filter_value" value="ConfigurableMsiProduct.sku"/> + <argument name="filter_value" value="$$downloadableProduct.product[sku]$$"/> </actionGroup> - <see selector="{{AdminGridRow.rowOne}}" userInput="{{colorProductAttribute1.name}}" - stepKey="seeProductNameInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="$100.00" stepKey="seeProductPriceInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="$$createSource.source[name]$$: 100" stepKey="seeProductQuantityInGrid"/> - <see selector="{{AdminGridRow.rowOne}}" userInput="$$createStock.stock[name]$$: 95" + <see selector="{{AdminGridRow.rowOne}}" userInput="$$createStock.stock[name]$$: 99" stepKey="seeProductSalableQuantityInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="Enabled" stepKey="seeProductStatusInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="Main Website" stepKey="seeProductWebsiteInGrid"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnDefaultStockFromHomepageTest.xml b/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnDefaultStockFromHomepageTest.xml index 76f982c946d7..3cd040dd6dbc 100644 --- a/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnDefaultStockFromHomepageTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/GuestCustomerOrderedDownloadableProductOnDefaultStockFromHomepageTest.xml @@ -20,6 +20,7 @@ </annotations> <before> + <magentoCLI stepKey="enableGuestCheckoutForDownloadable" command="config:set catalog/downloadable/disable_guest_checkout 0" /> <createData entity="FullSource1" stepKey="createSource"/> <createData entity="BasicMsiStockWithMainWebsite1" stepKey="createStock"/> <createData entity="SimpleSubCategory" stepKey="createCategory"/> @@ -44,129 +45,46 @@ stepKey="selectWebsiteAsSalesChannel"/> <click selector="{{AdminGridMainControls.saveAndContinue}}" stepKey="saveDefaultStock"/> - <!-- Create Configurable Product --> - <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToAdminProductGrid"/> - <waitForPageLoad time="30" stepKey="waitForProductGridLoad"/> - <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickOnAddProductToggle"/> - <click selector="{{AdminProductGridActionSection.addTypeProduct('configurable')}}" - stepKey="addConfigurableProduct"/> - <waitForPageLoad time="30" stepKey="waitForConfigurableProductNewPageLoad"/> - - <fillField userInput="{{ConfigurableMsiProduct.name}}" selector="{{AdminProductFormSection.productName}}" - stepKey="fillProductName"/> - <fillField userInput="{{ConfigurableMsiProduct.price}}" selector="{{AdminProductFormSection.productPrice}}" - stepKey="fillProductPrice"/> - <fillField userInput="{{ConfigurableMsiProduct.sku}}" selector="{{AdminProductFormSection.productSku}}" - stepKey="fillProductSku"/> - <fillField userInput="{{ConfigurableMsiProduct.quantity}}" - selector="{{AdminConfigurableProductFormSection.productQuantity}}" - stepKey="fillProductQuantity"/> - <fillField userInput="{{ConfigurableMsiProduct.weight}}" - selector="{{AdminConfigurableProductFormSection.productWeight}}" stepKey="fillProductWeight"/> - - <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" - parameterArray="[$$createCategory.name$$]" stepKey="searchAndSelectCategory"/> - <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" - stepKey="clickOnTheCreateConfigurationsButton"/> - <waitForElementVisible selector="{{AdminConfigurableProductSelectAttributesSlideOut.grid}}" time="30" - stepKey="waitForGridPresents"/> - - <click selector="{{AdminGridRow.checkboxByValue('color')}}" stepKey="selectColorAttribute"/> - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToSecondStep"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="addNewColorWhite"/> - <fillField userInput="{{colorProductAttribute1.name}}" - selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" stepKey="setNameWhite"/> - <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="saveWhiteColor"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="addNewColorRed"/> - <fillField userInput="{{colorProductAttribute2.name}}" - selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" stepKey="setNameRed"/> - <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="saveRedColor"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToThirdStep"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" - stepKey="clickOnApplySingleQuantityToEachSku"/> - - <click selector="{{AdminConfigurableProductAssignSourcesSlideOut.assignSources}}" - stepKey="openSelectSourcesModalWindow"/> - <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" - dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" - stepKey="clearSourcesFilter"/> - <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchDefaultByNameForAssignment"> - <argument name="keyword" value="_defaultSource.name"/> - </actionGroup> - <click selector="{{AdminGridRow.checkboxByValue(_defaultSource.name)}}" - stepKey="selectDefaultSource"/> - <click selector="{{AdminConfigurableProductAssignSourcesSlideOut.done}}" stepKey="doneAssignSources"/> - <fillField selector="{{AdminConfigurableProductAssignSourcesSlideOut.quantityPerSource('0')}}" - userInput="100" stepKey="fillQuantityForCustomSource"/> - - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="navigateToFourthStep"/> - <click selector="{{AdminCreateProductConfigurationsPanel.next}}" - stepKey="doneGeneratingConfigurableVariations"/> - - <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveConfigurableProduct"/> - <conditionalClick selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" - dependentSelector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" visible="true" - stepKey="confirmDefaultAttributeSetForConfigurableProduct"/> - <seeElement selector="{{AdminProductMessagesSection.successMessage}}" stepKey="checkProductSavedMessage"/> - - <seeNumberOfElements selector="{{AdminProductFormConfigurationsSection.currentVariationsRows}}" - userInput="2" stepKey="checkConfigurableMatrix"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteAttributeVariationName"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" - userInput="{{colorProductAttribute2.name}}" stepKey="checkRedAttributeVariationName"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteAttributeVariationSku"/> - <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" - userInput="{{colorProductAttribute2.name}}" stepKey="checkRedAttributeVariationSku"/> - <see selector="{{AdminConfigurableProductFormSection.currentVariationsQuantityCells}}" userInput="100" - stepKey="checkQtyIsCorrectForCustomSource"/> - - <actionGroup ref="AdminFormSaveAndClose" stepKey="saveAndClose"/> + <createData entity="MsiDownloadableProduct" stepKey="downloadableProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="downloadableLink1" stepKey="addDownloadableLink"> + <field key="is_shareable">1</field> + <requiredEntity createDataKey="downloadableProduct"/> + </createData> + <amOnPage url="{{AdminProductEditPage.url($$downloadableProduct.id$$)}}" stepKey="openProductEditPage" /> + <selectOption selector="{{AdminProductSourcesGrid.rowStatus('0')}}" userInput="In Stock" stepKey="selectStockStatus" /> + <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="100" stepKey="fillDefaultSourceQtyField"/> + <actionGroup ref="AdminFormSaveAndClose" stepKey="saveAndCloseProduct"/> </before> <after> + <magentoCLI stepKey="disableGuestCheckoutForDownloadable" command="config:set catalog/downloadable/disable_guest_checkout 1" /> <actionGroup ref="DisableSourceActionGroup" stepKey="disableSource"> <argument name="sourceCode" value="$$createSource.source[source_code]$$"/> </actionGroup> - <actionGroup ref="logout" stepKey="logoutOfAdmin"/> <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="downloadableProduct" stepKey="deleteProduct"/> + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> </after> - <actionGroup ref="AssertProductInStorefrontProductPage" stepKey="assertProductInStorefront"> - <argument name="product" value="ConfigurableMsiProduct"/> - </actionGroup> - - <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="navigateToCategory"/> - <waitForPageLoad time="30" stepKey="waitForCategoryPageLoad"/> - <click selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo(ConfigurableMsiProduct.name)}}" - stepKey="openProductPage"/> - <waitForAjaxLoad stepKey="waitForImageLoader"/> - <selectOption selector="{{StorefrontConfigurableProductPage.productAttributeDropDown}}" - userInput="{{colorProductAttribute1.name}}" stepKey="selectWhiteVariation"/> - <seeOptionIsSelected selector="{{StorefrontConfigurableProductPage.productAttributeDropDown}}" - userInput="{{colorProductAttribute1.name}}" stepKey="checkWhiteVariationIsSelected"/> - <fillField selector="{{StorefrontProductPageSection.qtyInput}}" userInput="5" stepKey="fillQuantity"/> + <amOnPage url="{{StorefrontProductPage.url($$downloadableProduct.custom_attributes[url_key]$$)}}" stepKey="openProductPage"/> + <waitForPageLoad stepKey="waitForImageLoader"/> <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="addToCart"/> - <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" - stepKey="waitForProductAdded"/> + <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" stepKey="waitForProductAdded"/> <!-- Place Order --> <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> <fillField selector="{{CheckoutShippingSection.email}}" userInput="{{MsiCustomer1.email}}" stepKey="enterEmail"/> - <fillField selector="#shipping-new-address-form input[name=firstname]" userInput="{{MsiCustomer1.firstname}}" stepKey="enterFirstName"/> - <fillField selector="#shipping-new-address-form input[name=lastname]" userInput="{{MsiCustomer1.lastname}}" stepKey="enterLastName"/> - <fillField selector="#shipping-new-address-form input[name='street[0]']" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="enterStreet"/> - <fillField selector="#shipping-new-address-form input[name=city]" userInput="{{CustomerAddressSimple.city}}" stepKey="enterCity"/> - <selectOption selector="#shipping-new-address-form select[name=region_id]" userInput="{{CustomerAddressSimple.state}}" stepKey="selectRegion"/> - <fillField selector="#shipping-new-address-form input[name=postcode]" userInput="{{CustomerAddressSimple.postcode}}" stepKey="enterPostcode"/> - <fillField selector="#shipping-new-address-form input[name=telephone]" userInput="{{CustomerAddressSimple.telephone}}" stepKey="enterTelephone"/> - - <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> + <fillField selector="{{CheckoutPaymentSection.guestFirstName}}" userInput="{{MsiCustomer1.firstname}}" stepKey="enterFirstName"/> + <fillField selector="{{CheckoutPaymentSection.guestLastName}}" userInput="{{MsiCustomer1.lastname}}" stepKey="enterLastName"/> + <fillField selector="{{CheckoutPaymentSection.guestStreet}}" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="enterStreet"/> + <fillField selector="{{CheckoutPaymentSection.guestCity}}" userInput="{{CustomerAddressSimple.city}}" stepKey="enterCity"/> + <selectOption selector="{{CheckoutPaymentSection.guestRegion}}" userInput="{{CustomerAddressSimple.state}}" stepKey="selectRegion"/> + <fillField selector="{{CheckoutPaymentSection.guestPostcode}}" userInput="{{CustomerAddressSimple.postcode}}" stepKey="enterPostcode"/> + <fillField selector="{{CheckoutPaymentSection.guestTelephone}}" userInput="{{CustomerAddressSimple.telephone}}" stepKey="enterTelephone"/> + + <click selector="{{CheckoutPaymentSection.update}}" stepKey="clickUpdate"/> <waitForPageLoad stepKey="waitForPageLoad"/> <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> @@ -188,15 +106,13 @@ <actionGroup ref="AdminGoToProductGridFilterResultsByInput" stepKey="goToProductGridFilterResultsByInput"> <argument name="filter_selector" value="AdminProductGridFilterSection.skuFilter"/> - <argument name="filter_value" value="ConfigurableMsiProduct.sku"/> + <argument name="filter_value" value="$$downloadableProduct.product[sku]$$"/> </actionGroup> - <see selector="{{AdminGridRow.rowOne}}" userInput="{{colorProductAttribute1.name}}" - stepKey="seeProductNameInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="$100.00" stepKey="seeProductPriceInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="{{_defaultSource.name}}: 100" stepKey="seeProductQuantityInGrid"/> - <see selector="{{AdminGridRow.rowOne}}" userInput="{{_defaultStock.name}}: 95" + <see selector="{{AdminGridRow.rowOne}}" userInput="{{_defaultStock.name}}: 99" stepKey="seeProductSalableQuantityInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="Enabled" stepKey="seeProductStatusInGrid"/> <see selector="{{AdminGridRow.rowOne}}" userInput="Main Website" stepKey="seeProductWebsiteInGrid"/> diff --git a/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductFromHomepageWithFreeShippingMethod.xml b/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductFromHomepageWithFreeShippingMethod.xml new file mode 100644 index 000000000000..12809c7f0ece --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductFromHomepageWithFreeShippingMethod.xml @@ -0,0 +1,26 @@ +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="LoggedInCustomerCreatedOrderWithSimpleProductFromHomepageWithFreeShippingMethod" extends="LoggedInCustomerCreatedOrderWithSimpleProductOnTestStockFromHomepage"> + <annotations> + <stories value="Logged In Customer created Order with Simple product from Homepage with Free Shipping method"/> + <title value="Logged In Customer created Order with Simple product from Homepage with Free Shipping method"/> + <description value="/scenarios/1408757"/> + <testCaseId value="MSI-2057"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <magentoCLI stepKey="enableFreeShipping" command="config:set carriers/freeshipping/active 1" before="loginAsAdmin"/> + </before> + <after> + <magentoCLI stepKey="disableFreeShipping" command="config:set carriers/freeshipping/active 0" after="logoutOfAdmin"/> + </after> + + <!--Free shipping--> + <click selector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('Free Shipping')}}" stepKey="selectFlatShippingMethod" after="waitForPaymentSelectionPageLoad" /> + <!--End Free shipping--> + + </test> +</tests> diff --git a/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductOnTestStockFromHomepageTest.xml b/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductOnTestStockFromHomepageTest.xml new file mode 100644 index 000000000000..b3d702040d3c --- /dev/null +++ b/InventoryAdminUi/Test/Mftf/Test/LoggedInCustomerCreatedOrderWithSimpleProductOnTestStockFromHomepageTest.xml @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="LoggedInCustomerCreatedOrderWithSimpleProductOnTestStockFromHomepage"> + <annotations> + <stories value="Logged In Customer created Order with Simple product on Test stock from Homepage"/> + <title value="Logged In Customer created Order with Simple product on Test stock from Homepage"/> + <description value="/scenarios/1408755"/> + <testCaseId value="MSI-2055"/> + <severity value="BLOCKER"/> + <group value="msi"/> + <group value="multi_mode"/> + </annotations> + + <before> + <magentoCLI stepKey="enableFreeShipping" command="config:set carriers/freeshipping/active 1" /> + <magentoCLI stepKey="disableFreeShipping" command="config:set carriers/freeshipping/active 0"/> + <createData entity="MsiCustomer1" stepKey="createCustomer"/> + <createData entity="BasicMsiStockWithMainWebsite1" stepKey="createStock"/> + <createData entity="FullSource1" stepKey="createSource"/> + <createData entity="SourceStockLinked1" stepKey="linkSourceStock"> + <requiredEntity createDataKey="createStock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <createData entity="SimpleSubCategory" stepKey="simpleCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct"> + <field key="qty">100.00</field> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <waitForPageLoad stepKey="waitForDashboardLoad"/> + </before> + <after> + <actionGroup ref="DisableSourceActionGroup" stepKey="disableThirdCreatedSource"> + <argument name="sourceCode" value="$$createSource.source[source_code]$$"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + </after> + + <!-- As Admin, find the Product to edit --> + <actionGroup ref="AdminGoToProductGridFilterResultsByInputEditProduct" stepKey="goToProductGridFilterResultsByInputEditProduct1"> + <argument name="filter_selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="filter_value" value="SimpleProduct.sku"/> + </actionGroup> + + <!-- As Admin, assign a source to the product --> + <actionGroup ref="AdminOnProductEditPageAssignSourceToProduct" stepKey="AdminOnProductEditPageAssignSourceToProduct1"> + <argument name="filter_selector" value="AdminManageSourcesGridFilterControls.code"/> + <argument name="filter_value" value="$$createSource.source[source_code]$$"/> + </actionGroup> + <!--Set qty to 100--> + <fillField selector="{{AdminProductSourcesGrid.rowQty('1')}}" userInput="100" stepKey="fillSourceQuantityField"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickOnSaveButton1"/> + + <!-- Login as customer --> + <comment userInput="Login to storefront as customer." stepKey="loginToStorefrontComment"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!-- Purchase product --> + <comment userInput="Purchase the created product" stepKey="PurchaseProduct"/> + <comment userInput="Purchase 5 simple product" stepKey="purchaseSimpleProductComment"/> + <amOnPage url="{{StorefrontCategoryPage.url($$simpleCategory.name$$)}}" stepKey="navigateToCategoryPage"/> + <moveMouseOver selector="{{StorefrontCategoryProductSection.ProductInfoByName($$simpleProduct.name$$)}}" stepKey="moveMouseOverProduct" /> + <click selector="{{StorefrontCategoryProductSection.ProductAddToCartByName($$simpleProduct.name$$)}}" stepKey="clickAddToCart" /> + <waitForElement selector="{{StorefrontMessagesSection.messageProductAddedToCart($$simpleProduct.name$$)}}" time="30" stepKey="assertMessage"/> + <waitForText userInput="1" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + + <click selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" stepKey="clickOnQtyField1"/> + <pressKey selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="\Facebook\WebDriver\WebDriverKeys::DELETE" stepKey="deleteExistingText1"/> + + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$simpleProduct.name$$)}}" userInput="5" stepKey="setProductQtyToFiftyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$simpleProduct.name$$)}}" stepKey="updateQtyInMiniCart"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPaymentSelectionPageLoad"/> + <click selector=".continue" stepKey="clickOnNextPaymentPage"/> + <waitForPageLoad stepKey="waitForPageLoadCheckoutSelectPayment"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment"/> + <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButtonVisible"/> + <see selector="{{CheckoutPaymentSection.billingAddress}}" userInput="{{US_Address_TX.street[0]}}" stepKey="chooseBillingAddress"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="placeOrder"/> + <waitForPageLoad stepKey="waitUntilOrderPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <see selector="{{CheckoutSuccessMainSection.success}}" userInput="Your order number is:" stepKey="checkOrderPlaceSuccessMessage"/> + + <!-- Admin area check ordered quantity --> + <comment userInput="Admin - Check ordered quantity" stepKey="AdminCheckOrderedQuantity"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderListPage"/> + <waitForLoadingMaskToDisappear stepKey="waitOrderListPageLoad"/> + <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrder"/> + <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearchCheckOrderAfterCustomerSubmits"/> + <waitForLoadingMaskToDisappear stepKey="waitFilteredOrderListPageLoad"/> + <click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="navigateToOrderViewPage"/> + <waitForElementVisible selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" stepKey="waitForViewOrderedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 5" stepKey="orderedQuantity"/> + + <!--Admin Area Check source quantity and salable quantity--> + <comment userInput="Admin - Check Source quantity and salable quantity after order placed" stepKey="AdminCheckQuantityAfterOrderPlaced"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPageForCheckProductQtyAfterPlaceOrder"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="findVirtualProductBySkuToCheckQtyAfterPlaceOrder"> + <argument name="selector" value="AdminProductGridFilterSection.skuFilter"/> + <argument name="value" value="$$simpleProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productQtyPerSource('1',$$createSource.source[name]$$)}}" userInput="100" stepKey="checkProductSourceQtyAfterPlaceOrder"/> + <see selector="{{AdminProductGridSection.productSalableQty('1',$$createStock.stock[name]$$)}}" userInput="95" stepKey="checkSalableQtyAfterPlaceOrder"/> + + <!--Admin area Reorder--> + <comment userInput="Admin - Reorder" stepKey="ReorderFromAdmin"/> + + + </test> +</tests> diff --git a/InventoryAdminUi/Test/Mftf/Test/StorefrontSimpleProductAssignedToNonDefaultSourceCreatedByAdminUserTest.xml b/InventoryAdminUi/Test/Mftf/Test/StorefrontSimpleProductAssignedToNonDefaultSourceCreatedByAdminUserTest.xml index 0dffa5825bec..350650159ce0 100644 --- a/InventoryAdminUi/Test/Mftf/Test/StorefrontSimpleProductAssignedToNonDefaultSourceCreatedByAdminUserTest.xml +++ b/InventoryAdminUi/Test/Mftf/Test/StorefrontSimpleProductAssignedToNonDefaultSourceCreatedByAdminUserTest.xml @@ -55,7 +55,6 @@ </actionGroup> <click selector="{{AdminAssignSourcesSlideOutGridSection.checkboxByCode($$createCustomSource.source[source_code]$$)}}" stepKey="clickOnCheckboxOnProductPage"/> <click selector="{{AdminAssignSourcesSlideOutSection.done}}" stepKey="clickOnDoneOnProductPage"/> - <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createCustomSource.source[source_code]$$" stepKey="seeSourceIdInGridOnProductPage"/> <see selector="{{AdminProductSourcesGrid.rowByIndex('0')}}" userInput="$$createCustomSource.source[name]$$" stepKey="seeSourceNameInGridOnProductPage"/> <fillField selector="{{AdminProductSourcesGrid.rowQty('0')}}" userInput="{{SimpleMsiProduct.quantity}}" stepKey="fillSourceQtyFieldOnProductPage"/> diff --git a/InventoryBundleProduct/Test/Integration/Order/PlaceOrderOnDefaultStockTest.php b/InventoryBundleProduct/Test/Integration/Order/PlaceOrderOnDefaultStockTest.php index 4b35f36a8492..6c5a82769a75 100644 --- a/InventoryBundleProduct/Test/Integration/Order/PlaceOrderOnDefaultStockTest.php +++ b/InventoryBundleProduct/Test/Integration/Order/PlaceOrderOnDefaultStockTest.php @@ -29,6 +29,7 @@ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * * @magentoDataFixture ../../../../app/code/Magento/InventoryBundleProduct/Test/_files/default_stock_bundle_products.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source.php * @magentoDataFixture ../../../../app/code/Magento/InventorySalesApi/Test/_files/quote.php * @magentoDataFixture ../../../../app/code/Magento/InventoryIndexer/Test/_files/reindex_inventory.php */ diff --git a/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source.php b/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source.php new file mode 100644 index 000000000000..48a5eea8383b --- /dev/null +++ b/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Api\DataObjectHelper; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory; +use Magento\InventoryApi\Api\SourceItemsSaveInterface; +use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var DataObjectHelper $dataObjectHelper */ +$dataObjectHelper = Bootstrap::getObjectManager()->get(DataObjectHelper::class); +/** @var SourceItemInterfaceFactory $sourceItemFactory */ +$sourceItemFactory = Bootstrap::getObjectManager()->get(SourceItemInterfaceFactory::class); +/** @var SourceItemsSaveInterface $sourceItemsSave */ +$sourceItemsSave = Bootstrap::getObjectManager()->get(SourceItemsSaveInterface::class); +/** @var DefaultSourceProviderInterface $defaultSourceProvider */ +$defaultSourceProvider = Bootstrap::getObjectManager()->get(DefaultSourceProviderInterface::class); + +$sourcesItemsData = [ + [ + SourceItemInterface::SOURCE_CODE => $defaultSourceProvider->getCode(), + SourceItemInterface::SKU => 'simple-out-of-stock', + SourceItemInterface::QUANTITY => 0, + SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK, + ], + [ + SourceItemInterface::SOURCE_CODE => $defaultSourceProvider->getCode(), + SourceItemInterface::SKU => 'simple', + SourceItemInterface::QUANTITY => 22, + SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK, + ] +]; + +$sourceItems = []; +foreach ($sourcesItemsData as $sourceItemData) { + /** @var SourceItemInterface $source */ + $sourceItem = $sourceItemFactory->create(); + $dataObjectHelper->populateWithArray($sourceItem, $sourceItemData, SourceItemInterface::class); + $sourceItems[] = $sourceItem; +} +$sourceItemsSave->execute($sourceItems); diff --git a/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source_rollback.php b/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source_rollback.php new file mode 100644 index 000000000000..7b5877623525 --- /dev/null +++ b/InventoryBundleProduct/Test/_files/source_items_for_bundle_options_on_default_source_rollback.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; +use Magento\InventoryApi\Api\SourceItemsDeleteInterface; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var SourceItemRepositoryInterface $sourceItemRepository */ +$sourceItemRepository = Bootstrap::getObjectManager()->get(SourceItemRepositoryInterface::class); +/** @var SourceItemsDeleteInterface $sourceItemsDelete */ +$sourceItemsDelete = Bootstrap::getObjectManager()->get(SourceItemsDeleteInterface::class); +/** @var SearchCriteriaBuilder $searchCriteriaBuilder */ +$searchCriteriaBuilder = Bootstrap::getObjectManager()->get(SearchCriteriaBuilder::class); + +$searchCriteria = $searchCriteriaBuilder->addFilter( + SourceItemInterface::SKU, + ['simple', 'simple-out-of-stock'], + 'in' +)->create(); +$sourceItems = $sourceItemRepository->getList($searchCriteria)->getItems(); + +/** + * Tests which are wrapped with MySQL transaction clear all data by transaction rollback. + * In that case there is "if" which checks that SKU1, SKU2 and SKU3 still exists in database. + */ +if (!empty($sourceItems)) { + $sourceItemsDelete->execute($sourceItems); +} diff --git a/InventoryCatalog/Model/BulkPartialInventoryTransfer.php b/InventoryCatalog/Model/BulkPartialInventoryTransfer.php new file mode 100644 index 000000000000..65b2de6205d1 --- /dev/null +++ b/InventoryCatalog/Model/BulkPartialInventoryTransfer.php @@ -0,0 +1,122 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Model; + +use Magento\Framework\Validation\ValidationException; +use Magento\CatalogInventory\Model\Indexer\Stock as LegacyIndexer; +use Magento\InventoryCatalog\Model\ResourceModel\TransferInventoryPartially; +use Magento\InventoryCatalogApi\Api\BulkPartialInventoryTransferInterface; +use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface; +use Magento\InventoryCatalogApi\Model\GetProductIdsBySkusInterface; +use Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorInterface; +use Magento\InventoryIndexer\Indexer\Source\SourceIndexer; + +class BulkPartialInventoryTransfer implements BulkPartialInventoryTransferInterface +{ + /** @var PartialInventoryTransferValidatorInterface */ + private $transferValidator; + + /** @var TransferInventoryPartially */ + private $transferCommand; + + /** @var GetProductIdsBySkusInterface */ + private $productIdsBySkus; + + /** @var DefaultSourceProviderInterface */ + private $defaultSourceProvider; + + /** @var SourceIndexer */ + private $sourceIndexer; + + /** @var LegacyIndexer */ + private $legacyIndexer; + + /** + * @param PartialInventoryTransferValidatorInterface $partialInventoryTransferValidator + * @param TransferInventoryPartially $transferInventoryPartiallyCommand + * @param GetProductIdsBySkusInterface $getProductIdsBySkus + * @param DefaultSourceProviderInterface $defaultSourceProvider + * @param SourceIndexer $sourceIndexer + * @param LegacyIndexer $legacyIndexer + */ + public function __construct( + PartialInventoryTransferValidatorInterface $partialInventoryTransferValidator, + TransferInventoryPartially $transferInventoryPartiallyCommand, + GetProductIdsBySkusInterface $getProductIdsBySkus, + DefaultSourceProviderInterface $defaultSourceProvider, + SourceIndexer $sourceIndexer, + LegacyIndexer $legacyIndexer + ) { + $this->transferValidator = $partialInventoryTransferValidator; + $this->transferCommand = $transferInventoryPartiallyCommand; + $this->productIdsBySkus = $getProductIdsBySkus; + $this->defaultSourceProvider = $defaultSourceProvider; + $this->sourceIndexer = $sourceIndexer; + $this->legacyIndexer = $legacyIndexer; + } + + /** + * Run bulk partial inventory transfer for specified items. + * + * @param string $originSourceCode + * @param string $destinationSourceCode + * @param PartialInventoryTransferItemInterface[] $items + * @return void + * @throws \Magento\Framework\Validation\ValidationException + */ + public function execute(string $originSourceCode, string $destinationSourceCode, array $items): void + { + $validationResult = $this->transferValidator->validate($originSourceCode, $destinationSourceCode, $items); + if (!$validationResult->isValid()) { + throw new ValidationException(__("Transfer validation failed"), null, 0, $validationResult); + } + + $this->processTransfer($originSourceCode, $destinationSourceCode, $items); + } + + /** + * @param string $originSourceCode + * @param string $destinationSourceCode + * @param PartialInventoryTransferItemInterface[] $items + */ + private function processTransfer(string $originSourceCode, string $destinationSourceCode, array $items): void + { + $processedSkus = []; + foreach ($items as $item) { + $this->transferCommand->execute($item, $originSourceCode, $destinationSourceCode); + $processedSkus[] = $item->getSku(); + } + + $this->updateIndexes([$originSourceCode, $destinationSourceCode], $processedSkus); + } + + /** + * @param string[] $sources + * @param string[] $skus + */ + private function updateIndexes(array $sources, array $skus) + { + $sources = array_unique($sources); + $this->sourceIndexer->executeList($sources); + + if (in_array($this->defaultSourceProvider->getCode(), $sources)) { + $this->updateLegacyIndex($skus); + } + } + + /** + * + * @param string[] $skus + */ + private function updateLegacyIndex(array $skus) + { + $productIds = $this->productIdsBySkus->execute($skus); + $this->legacyIndexer->executeList($productIds); + } +} \ No newline at end of file diff --git a/InventoryCatalog/Model/GetSourceItemsBySkuAndSourceCodes.php b/InventoryCatalog/Model/GetSourceItemsBySkuAndSourceCodes.php new file mode 100644 index 000000000000..b58efa1c0caf --- /dev/null +++ b/InventoryCatalog/Model/GetSourceItemsBySkuAndSourceCodes.php @@ -0,0 +1,48 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; + +class GetSourceItemsBySkuAndSourceCodes +{ + /** @var SearchCriteriaBuilder */ + private $searchCriteriaBuilder; + + /** @var SourceItemRepositoryInterface */ + private $sourceItemRepository; + + /** + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param SourceItemRepositoryInterface $sourceItemRepository + */ + public function __construct( + SearchCriteriaBuilder $searchCriteriaBuilder, + SourceItemRepositoryInterface $sourceItemRepository + ) { + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->sourceItemRepository = $sourceItemRepository; + } + + /** + * @param string $sku + * @param array $sourceCodes + * @return SourceItemInterface[] + */ + public function execute(string $sku, array $sourceCodes) + { + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, $sku) + ->addFilter(SourceItemInterface::SOURCE_CODE, [$sourceCodes], 'in') + ->create(); + + return $this->sourceItemRepository->getList($searchCriteria)->getItems(); + } +} \ No newline at end of file diff --git a/InventoryCatalog/Model/PartialInventoryTransferItem.php b/InventoryCatalog/Model/PartialInventoryTransferItem.php new file mode 100644 index 000000000000..f4923a8298b4 --- /dev/null +++ b/InventoryCatalog/Model/PartialInventoryTransferItem.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Model; + +use Magento\Framework\Api\AbstractSimpleObject; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface; + +class PartialInventoryTransferItem extends AbstractSimpleObject implements PartialInventoryTransferItemInterface +{ + + /** + * @return string + */ + public function getSku(): string + { + return $this->_get(self::SKU); + } + + /** + * @param string $sku + */ + public function setSku(string $sku): void + { + $this->setData(self::SKU, $sku); + } + + /** + * @return float + */ + public function getQty(): float + { + return $this->_get(self::QTY); + } + + /** + * @param float $qty + */ + public function setQty(float $qty): void + { + $this->setData(self::QTY, $qty); + } +} \ No newline at end of file diff --git a/InventoryCatalog/Model/ResourceModel/AddIsInStockFieldToCollection.php b/InventoryCatalog/Model/ResourceModel/AddIsInStockFieldToCollection.php index 033c9cb1d3bd..5fcf9df771a6 100644 --- a/InventoryCatalog/Model/ResourceModel/AddIsInStockFieldToCollection.php +++ b/InventoryCatalog/Model/ResourceModel/AddIsInStockFieldToCollection.php @@ -31,6 +31,8 @@ public function __construct( } /** + * Modify "is in stock" collection filter to support non-default sources. + * * @param Collection $collection * @param int $stockId * @return void diff --git a/InventoryCatalog/Model/ResourceModel/TransferInventoryPartially.php b/InventoryCatalog/Model/ResourceModel/TransferInventoryPartially.php new file mode 100644 index 000000000000..de7582f593d7 --- /dev/null +++ b/InventoryCatalog/Model/ResourceModel/TransferInventoryPartially.php @@ -0,0 +1,94 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\InventoryCatalog\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; +use Magento\Inventory\Model\ResourceModel\SourceItem; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface; + +class TransferInventoryPartially +{ + /** @var ResourceConnection */ + private $resourceConnection; + + /** @var DefaultSourceProviderInterface */ + private $defaultSourceProvider; + + /** @var SetDataToLegacyStockItem */ + private $setDataToLegacyStockItemCommand; + + /** + * @param ResourceConnection $resourceConnection + * @param DefaultSourceProviderInterface $defaultSourceProvider + * @param SetDataToLegacyStockItem $setDataToLegacyCatalogInventoryCommand + */ + public function __construct( + ResourceConnection $resourceConnection, + DefaultSourceProviderInterface $defaultSourceProvider, + SetDataToLegacyStockItem $setDataToLegacyCatalogInventoryCommand + ) { + $this->resourceConnection = $resourceConnection; + $this->defaultSourceProvider = $defaultSourceProvider; + $this->setDataToLegacyStockItemCommand = $setDataToLegacyCatalogInventoryCommand; + } + + /** + * @param PartialInventoryTransferItemInterface $transfer + * @param string $originSourceCode + * @param string $destinationSourceCode + */ + public function execute(PartialInventoryTransferItemInterface $transfer, string $originSourceCode, string $destinationSourceCode): void + { + $tableName = $this->resourceConnection->getTableName(SourceItem::TABLE_NAME_SOURCE_ITEM); + $connection = $this->resourceConnection->getConnection(); + $connection->beginTransaction(); + + $originSourceItemData = $this->getSourceItemData($transfer->getSku(), $originSourceCode); + $destSourceItemData = $this->getSourceItemData($transfer->getSku(), $destinationSourceCode); + + $updatedQtyAtOrigin = $originSourceItemData === null ? 0.0 : (float) $originSourceItemData[SourceItemInterface::QUANTITY] - $transfer->getQty(); + $updatedQtyAtDest = $destSourceItemData === null ? 0.0 : (float) $destSourceItemData[SourceItemInterface::QUANTITY] + $transfer->getQty(); + + $originUpdate = [SourceItemInterface::QUANTITY => $updatedQtyAtOrigin]; + $destUpdate = [SourceItemInterface::QUANTITY => $updatedQtyAtDest, SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK]; + + $connection->update($tableName, $originUpdate, [ + SourceItemInterface::SOURCE_CODE . '=?' => $originSourceCode, + SourceItemInterface::SKU . '=?' => $transfer->getSku(), + ]); + $connection->update($tableName, $destUpdate, [ + SourceItemInterface::SOURCE_CODE . '=?' => $destinationSourceCode, + SourceItemInterface::SKU . '=?' => $transfer->getSku(), + ]); + + if ($originSourceCode === $this->defaultSourceProvider->getCode()) { + $this->setDataToLegacyStockItemCommand->execute($transfer->getSku(), $updatedQtyAtOrigin, $originSourceItemData[SourceItemInterface::STATUS]); + } elseif ($destinationSourceCode === $this->defaultSourceProvider->getCode()) { + $this->setDataToLegacyStockItemCommand->execute($transfer->getSku(), $updatedQtyAtDest, SourceItemInterface::STATUS_IN_STOCK); + } + + $connection->commit(); + } + + private function getSourceItemData(string $sku, string $source): ?array + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName(SourceItem::TABLE_NAME_SOURCE_ITEM); + + $query = $connection->select()->from($tableName) + ->where(SourceItemInterface::SOURCE_CODE . ' = ?', $source) + ->where(SourceItemInterface::SKU . ' = ?', $sku); + + $res = $connection->fetchRow($query); + if ($res === false) { + return null; + } + + return $res; + } +} \ No newline at end of file diff --git a/InventoryCatalog/Model/Source/Validator/PartialTransferItemsValidator.php b/InventoryCatalog/Model/Source/Validator/PartialTransferItemsValidator.php new file mode 100644 index 000000000000..eda220ecfcf1 --- /dev/null +++ b/InventoryCatalog/Model/Source/Validator/PartialTransferItemsValidator.php @@ -0,0 +1,75 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Model\Source\Validator; + +use Magento\Framework\Validation\ValidationResult; +use Magento\Framework\Validation\ValidationResultFactory; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryCatalog\Model\GetSourceItemsBySkuAndSourceCodes; +use Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorInterface; + +class PartialTransferItemsValidator implements PartialInventoryTransferValidatorInterface +{ + /** @var ValidationResultFactory */ + private $validationResultFactory; + + /** @var GetSourceItemsBySkuAndSourceCodes */ + private $getSourceItem; + + /** + * @param ValidationResultFactory $validationResultFactory + * @param GetSourceItemsBySkuAndSourceCodes $getSourceItemsBySkuAndSourceCodes + */ + public function __construct( + ValidationResultFactory $validationResultFactory, + GetSourceItemsBySkuAndSourceCodes $getSourceItemsBySkuAndSourceCodes + ) { + $this->validationResultFactory = $validationResultFactory; + $this->getSourceItem = $getSourceItemsBySkuAndSourceCodes; + } + + /** + * @inheritdoc + */ + public function validate(string $originSourceCode, string $destinationSourceCode, array $items): ValidationResult + { + $errors = []; + + foreach ($items as $item) { + try { + $originSourceItem = $this->getSourceItemBySkuAndSource($item->getSku(), $originSourceCode); + if ($originSourceItem->getQuantity() < $item->getQty()) { + $errors[] = __('Requested transfer amount for sku %sku is not available', ['sku' => $item->getSku()]); + } + + $this->getSourceItemBySkuAndSource($item->getSku(), $destinationSourceCode); + } catch (NoSuchEntityException $e) { + $errors[] = __('%message', ['message' => $e->getMessage()]); + } + } + + return $this->validationResultFactory->create(['errors' => $errors]); + } + + /** + * @param string $sku + * @param string $sourceCode + * @return SourceItemInterface + * @throws NoSuchEntityException + */ + private function getSourceItemBySkuAndSource(string $sku, string $sourceCode): SourceItemInterface + { + $result = $this->getSourceItem->execute($sku, [$sourceCode]); + if (!count($result)) { + throw new NoSuchEntityException(__('Source item for %sku and %sourceCode does not exist', ['sku' => $sku, 'sourceCode' => $sourceCode])); + } + + return array_shift($result); + } +} \ No newline at end of file diff --git a/InventoryCatalog/Model/Source/Validator/PartialTransferSourceValidator.php b/InventoryCatalog/Model/Source/Validator/PartialTransferSourceValidator.php new file mode 100644 index 000000000000..22dd02c9064e --- /dev/null +++ b/InventoryCatalog/Model/Source/Validator/PartialTransferSourceValidator.php @@ -0,0 +1,61 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Model\Source\Validator; + +use Magento\Framework\Validation\ValidationResult; +use Magento\Framework\Validation\ValidationResultFactory; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\InventoryApi\Api\SourceRepositoryInterface; +use Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorInterface; + +class PartialTransferSourceValidator implements PartialInventoryTransferValidatorInterface +{ + /** @var ValidationResultFactory */ + private $validationResultFactory; + + /** @var SourceRepositoryInterface */ + private $sourceRepository; + + /** + * @param ValidationResultFactory $validationResultFactory + * @param SourceRepositoryInterface $sourceRepository + */ + public function __construct( + ValidationResultFactory $validationResultFactory, + SourceRepositoryInterface $sourceRepository + ) { + $this->validationResultFactory = $validationResultFactory; + $this->sourceRepository = $sourceRepository; + } + + /** + * @inheritdoc + */ + public function validate(string $originSourceCode, string $destinationSourceCode, array $items): ValidationResult + { + $errors = []; + + try { + $this->sourceRepository->get($originSourceCode); + } catch (NoSuchEntityException $e) { + $errors[] = __('Origin source %sourceCode does not exist', ['sourceCode' => $originSourceCode]); + } + + try { + $this->sourceRepository->get($destinationSourceCode); + } catch (NoSuchEntityException $e) { + $errors[] = __('Destination source %sourceCode does not exist', ['sourceCode' => $destinationSourceCode]); + } + + if ($originSourceCode === $destinationSourceCode) { + $errors[] = __('Cannot transfer a source on itself'); + } + + return $this->validationResultFactory->create(['errors' => $errors]); + } +} \ No newline at end of file diff --git a/InventoryCatalog/Plugin/CatalogInventory/Helper/Stock/AdaptAddInStockFilterToCollectionPlugin.php b/InventoryCatalog/Plugin/CatalogInventory/Helper/Stock/AdaptAddInStockFilterToCollectionPlugin.php index a625c704cfe2..bba3f6176db3 100644 --- a/InventoryCatalog/Plugin/CatalogInventory/Helper/Stock/AdaptAddInStockFilterToCollectionPlugin.php +++ b/InventoryCatalog/Plugin/CatalogInventory/Helper/Stock/AdaptAddInStockFilterToCollectionPlugin.php @@ -11,6 +11,7 @@ use Magento\CatalogInventory\Helper\Stock; use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; use Magento\InventoryCatalog\Model\ResourceModel\AddIsInStockFieldToCollection; +use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface; /** * Adapt addInStockFilterToCollection for multi stocks. @@ -27,19 +28,29 @@ class AdaptAddInStockFilterToCollectionPlugin */ private $addIsInStockFieldToCollection; + /** + * @var DefaultStockProviderInterface + */ + private $defaultStockProvider; + /** * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite * @param AddIsInStockFieldToCollection $addIsInStockFieldToCollection + * @param DefaultStockProviderInterface $defaultStockProvider */ public function __construct( GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite, - AddIsInStockFieldToCollection $addIsInStockFieldToCollection + AddIsInStockFieldToCollection $addIsInStockFieldToCollection, + DefaultStockProviderInterface $defaultStockProvider ) { $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite; $this->addIsInStockFieldToCollection = $addIsInStockFieldToCollection; + $this->defaultStockProvider = $defaultStockProvider; } /** + * Add filtering by "is in stock" criteria to the stock filter collection when source is not default. + * * @param Stock $subject * @param callable $proceed * @param Collection $collection @@ -50,6 +61,10 @@ public function __construct( public function aroundAddInStockFilterToCollection(Stock $subject, callable $proceed, $collection) { $stockId = $this->getStockIdForCurrentWebsite->execute(); - $this->addIsInStockFieldToCollection->execute($collection, $stockId); + if ($stockId === $this->defaultStockProvider->getId()) { + $proceed($collection); + } else { + $this->addIsInStockFieldToCollection->execute($collection, $stockId); + } } } diff --git a/InventoryCatalog/Plugin/InventoryApi/SetToZeroLegacyCatalogInventoryAtSourceItemsDeletePlugin.php b/InventoryCatalog/Plugin/InventoryApi/SetToZeroLegacyCatalogInventoryAtSourceItemsDeletePlugin.php index cda7741d1c62..b79951cc054f 100644 --- a/InventoryCatalog/Plugin/InventoryApi/SetToZeroLegacyCatalogInventoryAtSourceItemsDeletePlugin.php +++ b/InventoryCatalog/Plugin/InventoryApi/SetToZeroLegacyCatalogInventoryAtSourceItemsDeletePlugin.php @@ -102,7 +102,7 @@ public function afterExecute(SourceItemsDeleteInterface $subject, $result, array } $typeId = $this->getProductTypeBySku->execute([$sku])[$sku]; - if (false === $this->isSourceItemsAllowedForProductType->execute($typeId)) { + if (empty($typeId) || false === $this->isSourceItemsAllowedForProductType->execute($typeId)) { continue; } diff --git a/InventoryCatalog/Test/Api/Bulk/PartialInventoryTransferTest.php b/InventoryCatalog/Test/Api/Bulk/PartialInventoryTransferTest.php new file mode 100644 index 000000000000..feeec5ade83b --- /dev/null +++ b/InventoryCatalog/Test/Api/Bulk/PartialInventoryTransferTest.php @@ -0,0 +1,235 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Test\Api\Bulk; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Webapi\Exception; +use Magento\Framework\Webapi\Rest\Request; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferInterface; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\WebapiAbstract; + +class PartialInventoryTransferTest extends WebapiAbstract +{ + const RESOURCE_PATH = '/V1/inventory/bulk-partial-source-transfer'; + const VALIDATION_FAIL_MESSAGE = 'Transfer validation failed'; + + /** @var SourceItemRepositoryInterface */ + private $sourceItemRepository; + + /** @var SearchCriteriaBuilder */ + private $searchCriteriaBuilder; + + public function setUp() + { + $this->sourceItemRepository = Bootstrap::getObjectManager()->get(SourceItemRepositoryInterface::class); + $this->searchCriteriaBuilder = Bootstrap::getObjectManager()->get(SearchCriteriaBuilder::class); + } + + /** + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testValidTransfer() + { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST + ] + ]; + + $this->_webApiCall($serviceInfo, $this->getTransferItem('SKU-1', 1, 'eu-3', 'eu-2')); + + $originSourceItem = $this->getSourceItem('SKU-1', 'eu-3'); + $destinationSourceItem = $this->getSourceItem('SKU-1', 'eu-2'); + + if ($originSourceItem === null || $destinationSourceItem === null) { + $this->fail('Both source items should exist.'); + } + + $this->assertEquals(9, $originSourceItem->getQuantity()); + $this->assertEquals(4, $destinationSourceItem->getQuantity()); + } + + /** + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testInvalidTransferOrigin() + { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST + ] + ]; + + $expectedError = [ + 'message' => self::VALIDATION_FAIL_MESSAGE, + 'errors' => [ + [ + 'message' => 'Origin source %sourceCode does not exist', + 'parameters' => [ + 'sourceCode' => 'eu-999' + ] + ], + [ + 'message' => '%message', + 'parameters' => [ + 'message' => 'Source item for SKU-1 and eu-999 does not exist' + ] + ] + ] + ]; + $this->webApiCallWithException($serviceInfo, $this->getTransferItem('SKU-1', 1, 'eu-999', 'eu-2'), $expectedError); + } + + /** + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testInvalidTransferDestination() + { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST + ] + ]; + + $expectedError = [ + 'message' => self::VALIDATION_FAIL_MESSAGE, + 'errors' => [ + [ + 'message' => 'Destination source %sourceCode does not exist', + 'parameters' => [ + 'sourceCode' => 'eu-999' + ] + ], + [ + 'message' => '%message', + 'parameters' => [ + 'message' => 'Source item for SKU-1 and eu-999 does not exist' + ] + ] + ] + ]; + $this->webApiCallWithException($serviceInfo, $this->getTransferItem('SKU-1', 1, 'eu-3', 'eu-999'), $expectedError); + } + + /** + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testInvalidTransferOriginAndDestinationAreTheSame() + { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST + ] + ]; + + $expectedError = [ + 'message' => self::VALIDATION_FAIL_MESSAGE, + 'errors' => [ + [ + 'message' => 'Cannot transfer a source on itself', + 'parameters' => [] + ] + ] + ]; + $this->webApiCallWithException($serviceInfo, $this->getTransferItem('SKU-1', 1, 'eu-3', 'eu-3'), $expectedError); + } + + /** + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoApiDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testInvalidTransferQuantityGreaterThanAvailable() + { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST + ] + ]; + + $expectedError = [ + 'message' => self::VALIDATION_FAIL_MESSAGE, + 'errors' => [ + [ + 'message' => 'Requested transfer amount for sku %sku is not available', + 'parameters' => [ + 'sku' => 'SKU-1' + ] + ] + ] + ]; + $this->webApiCallWithException($serviceInfo, $this->getTransferItem('SKU-1', 100, 'eu-3', 'eu-2'), $expectedError); + } + + /** + * @param string $sku + * @param float $qty + * @param string $origin + * @param string $destination + * @return array + */ + private function getTransferItem(string $sku, float $qty, string $origin, string $destination): array + { + return [ + 'items' => [ + [PartialInventoryTransferItemInterface::SKU => $sku, PartialInventoryTransferItemInterface::QTY => $qty] + ], + 'origin_source_code' => $origin, + 'destination_source_code' => $destination + ]; + } + + /** + * @param array $serviceInfo + * @param array $data + * @param array $expectedError + */ + private function webApiCallWithException(array $serviceInfo, array $data, array $expectedError): void + { + try { + $this->_webApiCall($serviceInfo, $data); + $this->fail('An exception is expected but not thrown.'); + } catch (\Exception $e) { + self::assertEquals($expectedError, $this->processRestExceptionResult($e)); + self::assertEquals(Exception::HTTP_BAD_REQUEST, $e->getCode()); + } + } + + /** + * @param string $sku + * @param string $sourceCode + * @return SourceItemInterface|null + */ + private function getSourceItem(string $sku, string $sourceCode): ?SourceItemInterface + { + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, $sku) + ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCode) + ->create(); + + $sourceItems = $this->sourceItemRepository->getList($searchCriteria)->getItems(); + return empty($sourceItems) ? null : array_shift($sourceItems); + } +} diff --git a/InventoryCatalog/Test/Integration/GetSourceItemsBySkuAndSourceCodesTest.php b/InventoryCatalog/Test/Integration/GetSourceItemsBySkuAndSourceCodesTest.php new file mode 100644 index 000000000000..f286d3bd9828 --- /dev/null +++ b/InventoryCatalog/Test/Integration/GetSourceItemsBySkuAndSourceCodesTest.php @@ -0,0 +1,39 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalog\Test\Integration; + +use Magento\InventoryCatalog\Model\GetSourceItemsBySkuAndSourceCodes; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class GetSourceItemsBySkuAndSourceCodesTest extends TestCase +{ + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testExecuteSkuAssignedToSources() + { + $getSourceItems = Bootstrap::getObjectManager()->get(GetSourceItemsBySkuAndSourceCodes::class); + $items = $getSourceItems->execute('SKU-1', ['eu-1', 'eu-2']); + $this->assertEquals(2, count($items)); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + */ + public function testExecuteSkuNotAssignedToSources() + { + $getSourceItems = Bootstrap::getObjectManager()->get(GetSourceItemsBySkuAndSourceCodes::class); + $items = $getSourceItems->execute('SKU-2', ['eu-1']); + $this->assertEmpty($items); + } +} diff --git a/InventoryCatalog/etc/di.xml b/InventoryCatalog/etc/di.xml index beab754f1e6f..a6f836c38351 100644 --- a/InventoryCatalog/etc/di.xml +++ b/InventoryCatalog/etc/di.xml @@ -103,6 +103,11 @@ type="Magento\InventoryCatalog\Model\BulkSourceUnassign"/> <preference for="Magento\InventoryCatalogApi\Api\BulkInventoryTransferInterface" type="Magento\InventoryCatalog\Model\BulkInventoryTransfer"/> + <preference for="Magento\InventoryCatalogApi\Api\BulkPartialInventoryTransferInterface" + type="Magento\InventoryCatalog\Model\BulkPartialInventoryTransfer"/> + <preference for="Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface" + type="Magento\InventoryCatalog\Model\PartialInventoryTransferItem"/> + <type name="\Magento\InventoryCatalogApi\Model\BulkSourceAssignValidatorChain"> <arguments> <argument name="validators" xsi:type="array"> @@ -127,6 +132,17 @@ </argument> </arguments> </type> + <type name="\Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorChain"> + <arguments> + <argument name="validators" xsi:type="array"> + <item name="sources" + xsi:type="object">Magento\InventoryCatalog\Model\Source\Validator\PartialTransferSourceValidator</item> + <item name="items" + xsi:type="object">Magento\InventoryCatalog\Model\Source\Validator\PartialTransferItemsValidator</item> + </argument> + </arguments> + </type> + <!-- Negative Min Quantity Threshold for Backorders --> <type name="Magento\Catalog\Controller\Adminhtml\Product\Initialization\StockDataFilter"> <plugin name="allow_negative_min_qty" diff --git a/InventoryCatalogAdminUi/Test/Mftf/Section/AdminProductGridSection.xml b/InventoryCatalogAdminUi/Test/Mftf/Section/AdminProductGridSection.xml index 6d87bcd78aea..6354b8f4538a 100644 --- a/InventoryCatalogAdminUi/Test/Mftf/Section/AdminProductGridSection.xml +++ b/InventoryCatalogAdminUi/Test/Mftf/Section/AdminProductGridSection.xml @@ -12,4 +12,4 @@ <element name="productQtyPerSource" type="text" selector="//tr[{{row}}]//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., 'Quantity per Source')]/preceding-sibling::th) +1 ]//*[text()='{{sourceName}}']/following-sibling::span" parameterized="true"/> <element name="productSalableQty" type="text" selector="//tr[{{row}}]//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., 'Salable Quantity')]/preceding-sibling::th) +1 ]//*[text()='{{stockName}}']/following-sibling::span" parameterized="true"/> </section> -</sections> +</sections> \ No newline at end of file diff --git a/InventoryCatalogAdminUi/i18n/en_US.csv b/InventoryCatalogAdminUi/i18n/en_US.csv index 2bf1b90a9777..0c0f737c8157 100644 --- a/InventoryCatalogAdminUi/i18n/en_US.csv +++ b/InventoryCatalogAdminUi/i18n/en_US.csv @@ -48,3 +48,5 @@ Done,Done "Are you sure you want to unassign one or more sources from the selected items?","Are you sure you want to unassign one or more sources from the selected items?" "Transfer Inventory To Source","Transfer Inventory To Source" "Are you sure you want to transfer the inventory of the selected items?","Are you sure you want to transfer the inventory of the selected items?" +"Source code: ", "Source code: " +"Unassign","Unassign" diff --git a/InventoryCatalogAdminUi/view/adminhtml/ui_component/product_form.xml b/InventoryCatalogAdminUi/view/adminhtml/ui_component/product_form.xml index de9fd596cf63..bc0633f7c0a0 100644 --- a/InventoryCatalogAdminUi/view/adminhtml/ui_component/product_form.xml +++ b/InventoryCatalogAdminUi/view/adminhtml/ui_component/product_form.xml @@ -65,6 +65,7 @@ <dynamicRows name="assigned_sources" component="Magento_Ui/js/dynamic-rows/dynamic-rows-grid" sortOrder="20"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> + <item name="deleteButtonLabel" xsi:type="string">Unassign</item> <item name="dataProvider" xsi:type="string">data.sources.assign_sources_grid</item> <item name="map" xsi:type="array"> <item name="source_code" xsi:type="string">source_code</item> @@ -98,17 +99,8 @@ <item name="dataScope" xsi:type="string"/> </item> </argument> - <field name="source_code" formElement="input" sortOrder="10"> + <field name="name" formElement="input" sortOrder="20" template="Magento_InventoryCatalogAdminUi/dynamic-rows/cells/text"> <settings> - <elementTmpl>ui/dynamic-rows/cells/text</elementTmpl> - <dataType>text</dataType> - <dataScope>source_code</dataScope> - <label translate="true">Source Code</label> - </settings> - </field> - <field name="name" formElement="input" sortOrder="20"> - <settings> - <elementTmpl>ui/dynamic-rows/cells/text</elementTmpl> <dataType>text</dataType> <dataScope>name</dataScope> <label translate="true">Name</label> @@ -161,9 +153,9 @@ </imports> </settings> </field> - <field name="actionDelete" formElement="actionDelete" sortOrder="90"> + <field name="actionDelete" formElement="actionDelete" sortOrder="90" template="Magento_InventoryCatalogAdminUi/stock/assign-sources/action-delete"> <settings> - <label translate="true">Unassign</label> + <label translate="true"></label> </settings> </field> </container> diff --git a/InventoryCatalogAdminUi/view/adminhtml/web/template/dynamic-rows/cells/text.html b/InventoryCatalogAdminUi/view/adminhtml/web/template/dynamic-rows/cells/text.html new file mode 100644 index 000000000000..fa290c8974e1 --- /dev/null +++ b/InventoryCatalogAdminUi/view/adminhtml/web/template/dynamic-rows/cells/text.html @@ -0,0 +1,13 @@ +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<div class="control-table-text"> + <span attr="'data-index': index, 'title': $t('Source code: ')+$record().data().source_code" data-bind=" + text: value, + css: {_disabled: disabled} + "> + </span> +</div> diff --git a/InventoryCatalogAdminUi/view/adminhtml/web/template/stock/assign-sources/action-delete.html b/InventoryCatalogAdminUi/view/adminhtml/web/template/stock/assign-sources/action-delete.html index 5d00bc09e536..aec24deb6a2d 100644 --- a/InventoryCatalogAdminUi/view/adminhtml/web/template/stock/assign-sources/action-delete.html +++ b/InventoryCatalogAdminUi/view/adminhtml/web/template/stock/assign-sources/action-delete.html @@ -5,7 +5,7 @@ */ --> <button class="action-delete" - attr="{'data-action': 'remove_row', title: $parent.deleteButtonLabel}" + attr="{'data-action': 'remove_row', title: $t($parent.deleteButtonLabel)}" click="$data.deleteRecord.bind($data, $record().index, $record().recordId)" disable="disabled"> <span translate="$parent.deleteButtonLabel"></span> diff --git a/InventoryCatalogApi/Api/BulkPartialInventoryTransferInterface.php b/InventoryCatalogApi/Api/BulkPartialInventoryTransferInterface.php new file mode 100644 index 000000000000..ad484ebcdc30 --- /dev/null +++ b/InventoryCatalogApi/Api/BulkPartialInventoryTransferInterface.php @@ -0,0 +1,27 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalogApi\Api; + +/** + * Transfer Inventory between sources. Moves specified items from origin source to destination source. + * + * @api + */ +interface BulkPartialInventoryTransferInterface +{ + /** + * Run bulk partial inventory transfer for specified items. + * + * @param string $originSourceCode + * @param string $destinationSourceCode + * @param \Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface[] $items + * @return void + * @throws \Magento\Framework\Validation\ValidationException + */ + public function execute(string $originSourceCode, string $destinationSourceCode, array $items): void; +} diff --git a/InventoryCatalogApi/Api/Data/PartialInventoryTransferItemInterface.php b/InventoryCatalogApi/Api/Data/PartialInventoryTransferItemInterface.php new file mode 100644 index 000000000000..eae4c5eb374a --- /dev/null +++ b/InventoryCatalogApi/Api/Data/PartialInventoryTransferItemInterface.php @@ -0,0 +1,39 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalogApi\Api\Data; + +/** + * Specifies item and quantity for partial inventory transfer. + * + * @api + */ +interface PartialInventoryTransferItemInterface +{ + const SKU = 'sku'; + const QTY = 'qty'; + + /** + * @return string + */ + public function getSku(): string; + + /** + * @param string $sku + */ + public function setSku(string $sku): void; + + /** + * @return float + */ + public function getQty(): float; + + /** + * @param float $qty + */ + public function setQty(float $qty): void; +} diff --git a/InventoryCatalogApi/Model/PartialInventoryTransferValidatorChain.php b/InventoryCatalogApi/Model/PartialInventoryTransferValidatorChain.php new file mode 100644 index 000000000000..a9ac74d32984 --- /dev/null +++ b/InventoryCatalogApi/Model/PartialInventoryTransferValidatorChain.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalogApi\Model; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Validation\ValidationResult; +use Magento\Framework\Validation\ValidationResultFactory; + +/** + * Chain of validators. Extension point for new validators via di configuration + * + * @api + */ +class PartialInventoryTransferValidatorChain implements PartialInventoryTransferValidatorInterface +{ + /** + * @var ValidationResultFactory + */ + private $validationResultFactory; + + /** + * @var PartialInventoryTransferValidatorInterface[] + */ + private $validators; + + /** + * @param ValidationResultFactory $validationResultFactory + * @param PartialInventoryTransferValidatorInterface[] $validators + * @throws LocalizedException + * @SuppressWarnings(PHPMD.LongVariable) + */ + public function __construct( + ValidationResultFactory $validationResultFactory, + array $validators = [] + ) { + $this->validationResultFactory = $validationResultFactory; + + foreach ($validators as $validator) { + if (!$validator instanceof PartialInventoryTransferValidatorInterface) { + throw new LocalizedException( + __('Source Validator must implement PartialInventoryTransferValidatorInterface.') + ); + } + } + $this->validators = $validators; + } + + /** + * @inheritdoc + */ + public function validate(string $originSourceCode, string $destinationSourceCode, array $items): ValidationResult + { + $errors = []; + foreach ($this->validators as $validator) { + $validationResult = $validator->validate($originSourceCode, $destinationSourceCode, $items); + + if (!$validationResult->isValid()) { + $errors = array_merge($errors, $validationResult->getErrors()); + } + } + return $this->validationResultFactory->create(['errors' => $errors]); + } +} diff --git a/InventoryCatalogApi/Model/PartialInventoryTransferValidatorInterface.php b/InventoryCatalogApi/Model/PartialInventoryTransferValidatorInterface.php new file mode 100644 index 000000000000..7746e0a2c77b --- /dev/null +++ b/InventoryCatalogApi/Model/PartialInventoryTransferValidatorInterface.php @@ -0,0 +1,29 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryCatalogApi\Model; + +use Magento\Framework\Validation\ValidationResult; +use Magento\InventoryCatalogApi\Api\Data\PartialInventoryTransferItemInterface; + +/** + * Validator for Partial Inventory transfer API. + * + * @api + */ +interface PartialInventoryTransferValidatorInterface +{ + /** + * Validates a partial transfer request. + * + * @param string $originSourceCode + * @param string $destinationSourceCode + * @param PartialInventoryTransferItemInterface[] $items + * @return ValidationResult + */ + public function validate(string $originSourceCode, string $destinationSourceCode, array $items): ValidationResult; +} diff --git a/InventoryCatalogApi/etc/di.xml b/InventoryCatalogApi/etc/di.xml index 9f0e66a2a4ab..60beb25a0d9d 100644 --- a/InventoryCatalogApi/etc/di.xml +++ b/InventoryCatalogApi/etc/di.xml @@ -13,4 +13,6 @@ type="Magento\InventoryCatalogApi\Model\BulkSourceUnassignValidatorChain" /> <preference for="Magento\InventoryCatalogApi\Model\BulkInventoryTransferValidatorInterface" type="Magento\InventoryCatalogApi\Model\BulkInventoryTransferValidatorChain" /> + <preference for="Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorInterface" + type="Magento\InventoryCatalogApi\Model\PartialInventoryTransferValidatorChain"/> </config> \ No newline at end of file diff --git a/InventoryCatalogApi/etc/webapi.xml b/InventoryCatalogApi/etc/webapi.xml index 80897e53849d..76b14f4be02a 100644 --- a/InventoryCatalogApi/etc/webapi.xml +++ b/InventoryCatalogApi/etc/webapi.xml @@ -25,4 +25,10 @@ <resource ref="Magento_Catalog::products"/> </resources> </route> + <route url="/V1/inventory/bulk-partial-source-transfer" method="POST"> + <service class="Magento\InventoryCatalogApi\Api\BulkPartialInventoryTransferInterface" method="execute"/> + <resources> + <resource ref="Magento_Catalog::products"/> + </resources> + </route> </routes> \ No newline at end of file diff --git a/InventoryConfigurableProduct/Plugin/Sales/GetSkuFromOrderItem.php b/InventoryConfigurableProduct/Plugin/Sales/GetSkuFromOrderItem.php new file mode 100644 index 000000000000..6a4041a6d2b3 --- /dev/null +++ b/InventoryConfigurableProduct/Plugin/Sales/GetSkuFromOrderItem.php @@ -0,0 +1,40 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryConfigurableProduct\Plugin\Sales; + +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface; +use Magento\Sales\Api\Data\OrderItemInterface; + +/** + * Get simple product SKU from configurable order item + */ +class GetSkuFromOrderItem +{ + /** + * @param GetSkuFromOrderItemInterface $subject + * @param callable $proceed + * @param OrderItemInterface $orderItem + * @return string + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundExecute( + GetSkuFromOrderItemInterface $subject, + callable $proceed, + OrderItemInterface $orderItem + ): string { + if ($orderItem->getProductType() !== Configurable::TYPE_CODE) { + return $proceed($orderItem); + } + + $orderItemOptions = $orderItem->getProductOptions(); + $sku = $orderItemOptions['simple_sku']; + + return $sku; + } +} diff --git a/InventoryConfigurableProduct/Test/Integration/Order/PlaceOrderOnNotDefaultStockTest.php b/InventoryConfigurableProduct/Test/Integration/Order/PlaceOrderOnNotDefaultStockTest.php index ed8f56af0b5c..79ae12a5d836 100644 --- a/InventoryConfigurableProduct/Test/Integration/Order/PlaceOrderOnNotDefaultStockTest.php +++ b/InventoryConfigurableProduct/Test/Integration/Order/PlaceOrderOnNotDefaultStockTest.php @@ -170,14 +170,14 @@ public function testPlaceOrderWithOutOffStockProduct() * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/source_items_configurable.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/set_product_configurable_out_of_stock.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/set_product_configurable_zero_qty.php * @magentoDataFixture ../../../../app/code/Magento/InventorySalesApi/Test/_files/stock_website_sales_channels.php * @magentoDataFixture ../../../../app/code/Magento/InventorySalesApi/Test/_files/quote.php * @magentoConfigFixture store_for_us_website_store cataloginventory/item_options/backorders 1 * * @magentoDbIsolation disabled */ - public function testPlaceOrderWithOutOffStockProductAndBackOrdersTurnedOn() + public function testPlaceOrderWithZeroStockProductAndBackOrdersTurnedOn() { $sku = 'configurable'; $qty = 8; @@ -202,14 +202,15 @@ public function testPlaceOrderWithOutOffStockProductAndBackOrdersTurnedOn() * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/source_items_configurable.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/set_product_configurable_out_of_stock.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/set_product_configurable_zero_qty.php * @magentoDataFixture ../../../../app/code/Magento/InventorySalesApi/Test/_files/stock_website_sales_channels.php * @magentoDataFixture ../../../../app/code/Magento/InventorySalesApi/Test/_files/quote.php * @magentoConfigFixture current_store cataloginventory/item_options/manage_stock 0 + * @magentoConfigFixture default_store cataloginventory/item_options/manage_stock 0 * * @magentoDbIsolation disabled */ - public function testPlaceOrderWithOutOffStockProductAndManageStockTurnedOff() + public function testPlaceOrderWithZeroStockProductAndManageStockTurnedOff() { $sku = 'configurable'; $qty = 6; diff --git a/InventoryConfigurableProduct/Test/Integration/Sales/GetSkuFromOrderItemTest.php b/InventoryConfigurableProduct/Test/Integration/Sales/GetSkuFromOrderItemTest.php new file mode 100644 index 000000000000..64d4ad245392 --- /dev/null +++ b/InventoryConfigurableProduct/Test/Integration/Sales/GetSkuFromOrderItemTest.php @@ -0,0 +1,58 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryConfigurableProduct\Test\Integration\Sales; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface; +use Magento\Sales\Api\OrderItemRepositoryInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Class GetSkuFromOrderItemTest + */ +class GetSkuFromOrderItemTest extends TestCase +{ + /** + * @var OrderItemRepositoryInterface + */ + private $orderItemRepository; + + /** + * @var GetSkuFromOrderItemInterface + */ + private $getSkuFromOrderItemInterface; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + + $this->orderItemRepository = Bootstrap::getObjectManager()->get(OrderItemRepositoryInterface::class); + $this->getSkuFromOrderItemInterface = Bootstrap::getObjectManager()->get(GetSkuFromOrderItemInterface::class); + $this->searchCriteriaBuilder = Bootstrap::getObjectManager()->get(SearchCriteriaBuilder::class); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options.php + */ + public function testGetSkuFromConfigurableProductWithCustomOptionsOrderItem() + { + $orderItems = $this->orderItemRepository->getList($this->searchCriteriaBuilder->create()) + ->getItems(); + $sku = $this->getSkuFromOrderItemInterface->execute(current($orderItems)); + $this->assertEquals('configurable', $sku); + } +} diff --git a/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options.php b/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options.php new file mode 100644 index 000000000000..04baffbe736d --- /dev/null +++ b/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options.php @@ -0,0 +1,80 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require __DIR__ . '/../../../../../../dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +$addressData = include __DIR__ . '/../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/address_data.php'; + +$billingAddress = $objectManager->create(\Magento\Sales\Model\Order\Address::class, ['data' => $addressData]); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +$payment = $objectManager->create(\Magento\Sales\Model\Order\Payment::class); +$payment->setMethod('checkmo'); + +/** @var $product \Magento\Catalog\Model\Product */ +$product = $objectManager->create(\Magento\Catalog\Model\Product::class); +$product->load(1); + +/** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ +$eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); + +/** @var $options \Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection */ +$options = $objectManager->create(\Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection::class); +$option = $options->setAttributeFilter($attribute->getId()) + ->getFirstItem(); + +$requestInfo = [ + 'qty' => 1, + 'super_attribute' => [ + $attribute->getId() => $option->getId(), + ], +]; +/** @var \Magento\Sales\Model\Order $order */ +$order = $objectManager->create(\Magento\Sales\Model\Order::class); +$order->setIncrementId('100000001'); +$order->loadByIncrementId('100000001'); +if ($order->getId()) { + $order->delete(); +} +/** @var \Magento\Sales\Model\Order\Item $orderItem */ +$orderItem = $objectManager->create(\Magento\Sales\Model\Order\Item::class); +$orderItem->setProductId($product->getId()); +$orderItem->setQtyOrdered(1); +$orderItem->setBasePrice($product->getPrice()); +$orderItem->setPrice($product->getPrice()); +$orderItem->setRowTotal($product->getPrice()); +$orderItem->setProductType($product->getTypeId()); +$orderItem->setProductOptions([ + 'info_buyRequest' => $requestInfo, + 'simple_sku' => $product->getSku() +]); + +/** @var \Magento\Sales\Model\Order $order */ +$order = $objectManager->create(\Magento\Sales\Model\Order::class); +$order->setIncrementId('100000001'); +$order->setState(\Magento\Sales\Model\Order::STATE_NEW); +$order->setStatus($order->getConfig()->getStateDefaultStatus(\Magento\Sales\Model\Order::STATE_NEW)); +$order->setCustomerIsGuest(true); +$order->setCustomerEmail('customer@null.com'); +$order->setCustomerFirstname('firstname'); +$order->setCustomerLastname('lastname'); +$order->setBillingAddress($billingAddress); +$order->setShippingAddress($shippingAddress); +$order->setAddresses([$billingAddress, $shippingAddress]); +$order->setPayment($payment); +$order->addItem($orderItem); +$order->setStoreId($objectManager->get(\Magento\Store\Model\StoreManagerInterface::class)->getStore()->getId()); +$order->setSubtotal(100); +$order->setBaseSubtotal(100); +$order->setBaseGrandTotal(100); +$order->save(); diff --git a/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options_rollback.php b/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options_rollback.php new file mode 100644 index 000000000000..9899e4bdcc22 --- /dev/null +++ b/InventoryConfigurableProduct/Test/_files/order_item_with_configurable_and_options_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require __DIR__ . '/../../../../../../dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_rollback.php'; +require __DIR__ . '/../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/default_rollback.php'; diff --git a/InventoryConfigurableProduct/Test/_files/set_product_configurable_zero_qty.php b/InventoryConfigurableProduct/Test/_files/set_product_configurable_zero_qty.php new file mode 100644 index 000000000000..aebbf312070c --- /dev/null +++ b/InventoryConfigurableProduct/Test/_files/set_product_configurable_zero_qty.php @@ -0,0 +1,29 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Inventory\Model\SourceItem\Command\SourceItemsSave; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var SourceItemRepositoryInterface $sourceItemRepository */ +$sourceItemRepository = Bootstrap::getObjectManager()->create(SourceItemRepositoryInterface::class); + +$searchCriteriaBuilder = Bootstrap::getObjectManager()->create(SearchCriteriaBuilder::class); +$searchCriteria = $searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, 'simple_10') + ->addFilter(SourceItemInterface::SOURCE_CODE, 'us-1') + ->create(); + +$sourceItems = $sourceItemRepository->getList($searchCriteria)->getItems(); +$sourceItem = reset($sourceItems); +$sourceItem->setStatus(1); + +/** @var SourceItemsSave $sourceItemSave */ +$sourceItemSave = Bootstrap::getObjectManager()->create(SourceItemsSave::class); +$sourceItemSave->execute([$sourceItem]); diff --git a/InventoryConfigurableProduct/composer.json b/InventoryConfigurableProduct/composer.json index d3b07082cf65..d193793ff8ef 100644 --- a/InventoryConfigurableProduct/composer.json +++ b/InventoryConfigurableProduct/composer.json @@ -9,9 +9,8 @@ "magento/module-inventory-catalog-api": "*", "magento/module-inventory-indexer": "*", "magento/module-store": "*", - "magento/module-catalog-inventory": "*" - }, - "suggest": { + "magento/module-catalog-inventory": "*", + "magento/module-sales": "*", "magento/module-configurable-product": "*" }, "type": "magento2-module", diff --git a/InventoryConfigurableProduct/etc/di.xml b/InventoryConfigurableProduct/etc/di.xml index 9be2ba9af8ea..a8b4f776a2fe 100644 --- a/InventoryConfigurableProduct/etc/di.xml +++ b/InventoryConfigurableProduct/etc/di.xml @@ -14,4 +14,8 @@ </argument> </arguments> </type> + <type name="Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface"> + <plugin name="get_configurable_option_sku_from_order" + type="Magento\InventoryConfigurableProduct\Plugin\Sales\GetSkuFromOrderItem"/> + </type> </config> diff --git a/InventoryConfigurableProductAdminUi/Observer/ProcessSourceItemsObserver.php b/InventoryConfigurableProductAdminUi/Observer/ProcessSourceItemsObserver.php index bea7a7ca5bc1..4e38dfce50de 100644 --- a/InventoryConfigurableProductAdminUi/Observer/ProcessSourceItemsObserver.php +++ b/InventoryConfigurableProductAdminUi/Observer/ProcessSourceItemsObserver.php @@ -85,8 +85,9 @@ public function execute(EventObserver $observer) private function processSourceItems(array $sourceItems, string $productSku) { foreach ($sourceItems as $key => $sourceItem) { + $sourceItems[$key][SourceItemInterface::QUANTITY] = $sourceItems[$key]['quantity_per_source']; + if (!isset($sourceItem[SourceItemInterface::STATUS])) { - $sourceItems[$key][SourceItemInterface::QUANTITY] = $sourceItems[$key]['quantity_per_source']; $sourceItems[$key][SourceItemInterface::STATUS] = $sourceItems[$key][SourceItemInterface::QUANTITY] > 0 ? 1 : 0; } diff --git a/InventoryExportStock/LICENSE.txt b/InventoryExportStock/LICENSE.txt new file mode 100644 index 000000000000..49525fd99da9 --- /dev/null +++ b/InventoryExportStock/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/InventoryExportStock/LICENSE_AFL.txt b/InventoryExportStock/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/InventoryExportStock/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/InventoryExportStock/Model/ExportStockIndexData.php b/InventoryExportStock/Model/ExportStockIndexData.php new file mode 100644 index 000000000000..470eb6ab352b --- /dev/null +++ b/InventoryExportStock/Model/ExportStockIndexData.php @@ -0,0 +1,84 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryExportStock\Model\ResourceModel\StockIndexDumpProcessor; +use Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterface; +use Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterfaceFactory; +use Magento\InventoryExportStockApi\Api\ExportStockIndexDataInterface; +use Magento\InventorySales\Model\ResourceModel\GetWebsiteIdByWebsiteCode; +use Magento\InventorySalesApi\Api\Data\SalesChannelInterface; +use Magento\InventorySalesApi\Api\StockResolverInterface; + +/** + * Class ExportStockIndexData provides stock index export + */ +class ExportStockIndexData implements ExportStockIndexDataInterface +{ + /** + * @var StockIndexDumpProcessor + */ + private $stockIndexDumpProcessor; + + /** + * @var GetWebsiteIdByWebsiteCode + */ + private $getWebsiteIdByWebsiteCode; + + /** + * @var StockResolverInterface + */ + private $stockResolver; + + /** + * @var ProductStockIndexDataMapper + */ + private $productStockIndexDataMapper; + + /** + * ExportStockIndexData constructor + * + * @param StockIndexDumpProcessor $stockIndexDumpProcessor + * @param GetWebsiteIdByWebsiteCode $getWebsiteIdByWebsiteCode + * @param StockResolverInterface $stockResolver + * @param ProductStockIndexDataMapper $productStockIndexDataMapper + */ + public function __construct( + StockIndexDumpProcessor $stockIndexDumpProcessor, + GetWebsiteIdByWebsiteCode $getWebsiteIdByWebsiteCode, + StockResolverInterface $stockResolver, + ProductStockIndexDataMapper $productStockIndexDataMapper + ) { + $this->stockIndexDumpProcessor = $stockIndexDumpProcessor; + $this->getWebsiteIdByWebsiteCode = $getWebsiteIdByWebsiteCode; + $this->stockResolver = $stockResolver; + $this->productStockIndexDataMapper = $productStockIndexDataMapper; + } + + /** + * Provides stock index export from inventory_stock_% table + * + * @param string $websiteCode + * @return ProductStockIndexDataInterface[] + * @throws LocalizedException + */ + public function execute(string $websiteCode): array + { + $websiteId = $this->getWebsiteIdByWebsiteCode->execute($websiteCode); + $stockId = $this->stockResolver + ->execute(SalesChannelInterface::TYPE_WEBSITE, $websiteCode)->getStockId(); + $items = $this->stockIndexDumpProcessor->execute($websiteId, $stockId); + $productsData = []; + foreach ($items as $item) { + $productsData[] = $this->productStockIndexDataMapper->execute($item); + } + + return $productsData; + } +} diff --git a/InventoryExportStock/Model/ExportStockSalableQty.php b/InventoryExportStock/Model/ExportStockSalableQty.php new file mode 100644 index 000000000000..17fa6ad4d31f --- /dev/null +++ b/InventoryExportStock/Model/ExportStockSalableQty.php @@ -0,0 +1,98 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\Api\SearchResultsInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterface; +use Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterfaceFactory; +use Magento\InventoryExportStockApi\Api\ExportStockSalableQtyInterface; +use Magento\InventorySalesApi\Api\Data\SalesChannelInterface; +use Magento\InventorySalesApi\Api\StockResolverInterface; + +/** + * Class ExportStockSalableQty provides product stock information by search criteria + */ +class ExportStockSalableQty implements ExportStockSalableQtyInterface +{ + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var ExportStockSalableQtySearchResultInterfaceFactory + */ + private $exportStockSalableQtySearchResultFactory; + + /** + * @var PreciseExportStockProcessor + */ + private $preciseExportStockProcessor; + + /** + * @var StockResolverInterface + */ + private $stockResolver; + + /** + * ExportStockSalableQty constructor + * + * @param ProductRepositoryInterface $productRepository + * @param ExportStockSalableQtySearchResultInterfaceFactory $exportStockSalableQtySearchResultFactory + * @param PreciseExportStockProcessor $preciseExportStockProcessor + * @param StockResolverInterface $stockResolver + */ + public function __construct( + ProductRepositoryInterface $productRepository, + ExportStockSalableQtySearchResultInterfaceFactory $exportStockSalableQtySearchResultFactory, + PreciseExportStockProcessor $preciseExportStockProcessor, + StockResolverInterface $stockResolver + ) { + $this->productRepository = $productRepository; + $this->exportStockSalableQtySearchResultFactory = $exportStockSalableQtySearchResultFactory; + $this->preciseExportStockProcessor = $preciseExportStockProcessor; + $this->stockResolver = $stockResolver; + } + + /** + * @inheritDoc + * + * @throws LocalizedException + */ + public function execute( + SearchCriteriaInterface $searchCriteria, + string $websiteCode + ): ExportStockSalableQtySearchResultInterface { + $stockId = $this->stockResolver + ->execute(SalesChannelInterface::TYPE_WEBSITE, $websiteCode)->getStockId(); + $productSearchResult = $this->getProducts($searchCriteria); + $items = $this->preciseExportStockProcessor + ->execute($productSearchResult->getItems(), $stockId); + /** @var ExportStockSalableQtySearchResultInterface $searchResult */ + $searchResult = $this->exportStockSalableQtySearchResultFactory->create(); + $searchResult->setSearchCriteria($productSearchResult->getSearchCriteria()); + $searchResult->setItems($items); + $searchResult->setTotalCount(count($items)); + + return $searchResult; + } + + /** + * Provides product search result by search criteria + * + * @param SearchCriteriaInterface $searchCriteria + * @return SearchResultsInterface + */ + private function getProducts(SearchCriteriaInterface $searchCriteria): SearchResultsInterface + { + return $this->productRepository->getList($searchCriteria); + } +} diff --git a/InventoryExportStock/Model/ExportStockSalableQtySearchResult.php b/InventoryExportStock/Model/ExportStockSalableQtySearchResult.php new file mode 100644 index 000000000000..6f569f6141f7 --- /dev/null +++ b/InventoryExportStock/Model/ExportStockSalableQtySearchResult.php @@ -0,0 +1,18 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\Framework\Api\Search\SearchResult; +use Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterface; + +/** + * Class ExportStockSalableQtyExportStockSalableQtySearchResult + */ +class ExportStockSalableQtySearchResult extends SearchResult implements ExportStockSalableQtySearchResultInterface +{ +} diff --git a/InventoryExportStock/Model/GetQtyForNotManageStock.php b/InventoryExportStock/Model/GetQtyForNotManageStock.php new file mode 100644 index 000000000000..4b2ac88b102a --- /dev/null +++ b/InventoryExportStock/Model/GetQtyForNotManageStock.php @@ -0,0 +1,40 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +/** + * Class GetQtyForNotManageStock provides qtyForNotManageStock from di configuration + */ +class GetQtyForNotManageStock +{ + /** + * @var float|null + */ + private $qtyForNotManageStock; + + /** + * GetQtyForNotManageStock constructor + * + * @param float|null $qtyForNotManageStock + */ + public function __construct( + ?float $qtyForNotManageStock + ) { + $this->qtyForNotManageStock = $qtyForNotManageStock; + } + + /** + * Provides qtyForNotManageStock from di configuration + * + * @return float|null + */ + public function execute(): ?float + { + return $this->qtyForNotManageStock; + } +} diff --git a/InventoryExportStock/Model/GetStockItemConfiguration.php b/InventoryExportStock/Model/GetStockItemConfiguration.php new file mode 100644 index 000000000000..74b60217a95a --- /dev/null +++ b/InventoryExportStock/Model/GetStockItemConfiguration.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\InventoryConfiguration\Model\GetLegacyStockItem; +use Magento\InventoryConfiguration\Model\StockItemConfigurationFactory; +use Magento\InventoryConfigurationApi\Api\Data\StockItemConfigurationInterface; + +/** + * @inheritdoc + */ +class GetStockItemConfiguration +{ + /** + * @var GetLegacyStockItem + */ + private $getLegacyStockItem; + + /** + * @var StockItemConfigurationFactory + */ + private $stockItemConfigurationFactory; + + /** + * @param GetLegacyStockItem $getLegacyStockItem + * @param StockItemConfigurationFactory $stockItemConfigurationFactory + */ + public function __construct( + GetLegacyStockItem $getLegacyStockItem, + StockItemConfigurationFactory $stockItemConfigurationFactory + ) { + $this->getLegacyStockItem = $getLegacyStockItem; + $this->stockItemConfigurationFactory = $stockItemConfigurationFactory; + } + + /** + * @inheritdoc + */ + public function execute(string $sku): StockItemConfigurationInterface + { + return $this->stockItemConfigurationFactory->create( + [ + 'stockItem' => $this->getLegacyStockItem->execute($sku) + ] + ); + } +} diff --git a/InventoryExportStock/Model/PreciseExportStockProcessor.php b/InventoryExportStock/Model/PreciseExportStockProcessor.php new file mode 100644 index 000000000000..b09ce38562c0 --- /dev/null +++ b/InventoryExportStock/Model/PreciseExportStockProcessor.php @@ -0,0 +1,153 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryApi\Model\IsProductAssignedToStockInterface; +use Magento\InventoryConfigurationApi\Exception\SkuIsNotAssignedToStockException; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface; +use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface; +use Magento\InventorySalesApi\Api\IsProductSalableInterface; + +/** + * Class Provides stock data with reservation taken into in account + */ +class PreciseExportStockProcessor +{ + /** + * @var IsSourceItemManagementAllowedForSkuInterface + */ + private $isSourceItemManagementAllowedForSku; + + /** + * @var GetProductSalableQtyInterface + */ + private $getProductSalableQty; + + /** + * @var GetStockItemConfiguration + */ + private $getStockItemConfiguration; + + /** + * @var GetQtyForNotManageStock + */ + private $getQtyForNotManageStock; + + /** + * @var IsProductSalableInterface + */ + private $isProductSalable; + + /** + * @var IsProductAssignedToStockInterface + */ + private $isProductAssignedToStock; + + /** + * @param IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku + * @param GetProductSalableQtyInterface $getProductSalableQty + * @param GetQtyForNotManageStock $getQtyForNotManageStock + * @param IsProductSalableInterface $isProductSalable + * @param GetStockItemConfiguration $getStockItemConfiguration + * @param IsProductAssignedToStockInterface $isProductAssignedToStock + */ + public function __construct( + IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku, + GetProductSalableQtyInterface $getProductSalableQty, + GetQtyForNotManageStock $getQtyForNotManageStock, + IsProductSalableInterface $isProductSalable, + GetStockItemConfiguration $getStockItemConfiguration, + IsProductAssignedToStockInterface $isProductAssignedToStock + ) { + $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku; + $this->getProductSalableQty = $getProductSalableQty; + $this->getStockItemConfiguration = $getStockItemConfiguration; + $this->getQtyForNotManageStock = $getQtyForNotManageStock; + $this->isProductSalable = $isProductSalable; + $this->isProductAssignedToStock = $isProductAssignedToStock; + } + + /** + * Provides precise method for getting stock data + * + * @param array $products + * @param int $stockId + * @return array + * @throws LocalizedException + */ + public function execute(array $products, int $stockId): array + { + $skus = $this->getProductSkus($products); + $items = []; + foreach ($skus as $sku) { + try { + $items[] = $this->getItem($sku, $stockId); + } catch (SkuIsNotAssignedToStockException $e) { + continue; + } + + } + + return $items; + } + + /** + * Extracts product skus from $product array + * + * @param array $products + * @return array + */ + private function getProductSkus(array $products): array + { + $skus = []; + /** @var ProductInterface $product */ + foreach ($products as $product) { + $skus[] = $product->getSku(); + } + + return $skus; + } + + /** + * Provides is product salable, and is salable by sku + * + * @param string $sku + * @param int $stockId + * @return array + * @throws SkuIsNotAssignedToStockException + * @throws LocalizedException + */ + private function getItem(string $sku, int $stockId): array + { + if (!$this->isSourceItemManagementAllowedForSku->execute($sku)) { + return [ + 'sku' => $sku, + 'qty' => 0.0000, + 'is_salable' => $this->isProductSalable->execute($sku, $stockId) + ]; + } + if (!$this->getStockItemConfiguration->execute($sku)->isManageStock()) { + return [ + 'sku' => $sku, + 'qty' => (float)$this->getQtyForNotManageStock->execute(), + 'is_salable' => true + ]; + } + if (!$this->isProductAssignedToStock->execute($sku, $stockId)) { + throw new SkuIsNotAssignedToStockException(__('The requested sku is not assigned to given stock.')); + } + + return [ + 'sku' => $sku, + 'qty' => $this->getProductSalableQty->execute($sku, $stockId), + 'is_salable' => $this->isProductSalable->execute($sku, $stockId) + ]; + } +} diff --git a/InventoryExportStock/Model/ProductStockIndexData.php b/InventoryExportStock/Model/ProductStockIndexData.php new file mode 100644 index 000000000000..bf80c8de57db --- /dev/null +++ b/InventoryExportStock/Model/ProductStockIndexData.php @@ -0,0 +1,65 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\Framework\Model\AbstractModel; +use Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterface; + +/** + * Class ProductStockIndexData + */ +class ProductStockIndexData extends AbstractModel implements ProductStockIndexDataInterface +{ + /** + * @inheritDoc + */ + public function getSku(): string + { + return $this->getData(self::SKU); + } + + /** + * @inheritDoc + */ + public function getQty(): float + { + return $this->getData(self::QTY); + } + + /** + * @inheritDoc + */ + public function getIsSalable(): bool + { + return $this->getData(self::IS_SALABLE); + } + + /** + * @inheritDoc + */ + public function setSku(string $sku): void + { + $this->setData(self::SKU, $sku); + } + + /** + * @inheritDoc + */ + public function setQty(float $qty): void + { + $this->setData(self::QTY, $qty); + } + + /** + * @inheritDoc + */ + public function setIsSalable(bool $isSalable): void + { + $this->setData(self::IS_SALABLE, $isSalable); + } +} diff --git a/InventoryExportStock/Model/ProductStockIndexDataMapper.php b/InventoryExportStock/Model/ProductStockIndexDataMapper.php new file mode 100644 index 000000000000..31a179c81e16 --- /dev/null +++ b/InventoryExportStock/Model/ProductStockIndexDataMapper.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model; + +use Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterface; + +/** + * Class ProductStockIndexDataMapper + */ +class ProductStockIndexDataMapper +{ + /** + * @var ProductStockIndexDataFactory + */ + private $productStockIndexDataFactory; + + /** + * @param ProductStockIndexDataFactory $productStockIndexDataFactory + */ + public function __construct( + ProductStockIndexDataFactory $productStockIndexDataFactory + ) { + $this->productStockIndexDataFactory = $productStockIndexDataFactory; + } + + /** + * Creates ProductStockIndexData object and set values inside of it + * + * @param array $item + * @return ProductStockIndexDataInterface + */ + public function execute(array $item): ProductStockIndexDataInterface + { + /** @var ProductStockIndexDataInterface $productStockDataObject */ + $productStockDataObject = $this->productStockIndexDataFactory->create(); + $productStockDataObject->setSku($item[ProductStockIndexDataInterface::SKU]); + $productStockDataObject->setIsSalable((bool)$item[ProductStockIndexDataInterface::IS_SALABLE]); + $productStockDataObject->setQty((float)$item[ProductStockIndexDataInterface::QTY]); + + return $productStockDataObject; + } +} diff --git a/InventoryExportStock/Model/ResourceModel/ManageStockCondition.php b/InventoryExportStock/Model/ResourceModel/ManageStockCondition.php new file mode 100644 index 000000000000..03d65c6bba17 --- /dev/null +++ b/InventoryExportStock/Model/ResourceModel/ManageStockCondition.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model\ResourceModel; + +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\Framework\DB\Select; + +/** + * Class ManageStockCondition + */ +class ManageStockCondition +{ + /** + * @var StockConfigurationInterface + */ + private $configuration; + + /** + * @param StockConfigurationInterface $configuration + */ + public function __construct(StockConfigurationInterface $configuration) + { + $this->configuration = $configuration; + } + + /** + * Provide product manage stock condition for db select + * + * @param Select $select + * @return string + */ + public function execute(Select $select): string + { + $globalManageStock = (int)$this->configuration->getManageStock(); + + $condition = ' + (legacy_stock_item.use_config_manage_stock = 0 AND legacy_stock_item.manage_stock = 1)'; + if (1 === $globalManageStock) { + $condition .= ' OR legacy_stock_item.use_config_manage_stock = 1'; + } + + return $condition; + } +} diff --git a/InventoryExportStock/Model/ResourceModel/StockIndexDumpProcessor.php b/InventoryExportStock/Model/ResourceModel/StockIndexDumpProcessor.php new file mode 100644 index 000000000000..055c9f1afa6c --- /dev/null +++ b/InventoryExportStock/Model/ResourceModel/StockIndexDumpProcessor.php @@ -0,0 +1,218 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStock\Model\ResourceModel; + +use Magento\Catalog\Model\Product\Type; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\Exception\LocalizedException; +use Magento\GroupedProduct\Model\Product\Type\Grouped; +use Magento\InventoryExportStock\Model\GetQtyForNotManageStock; +use Magento\InventoryIndexer\Model\StockIndexTableNameResolverInterface; +use Magento\InventorySales\Model\ResourceModel\IsStockItemSalableCondition\ManageStockCondition as NotManageStockCondition; +use Psr\Log\LoggerInterface; + +/** + * Class GetStockIndexDump provides sku and qty of products dumping them from stock index table + */ +class StockIndexDumpProcessor +{ + /** + * @var StockIndexTableNameResolverInterface + */ + private $stockIndexTableNameResolver; + + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @var NotManageStockCondition + */ + private $notManageStockCondition; + + /** + * @var AdapterInterface + */ + private $connection; + + /** + * @var GetQtyForNotManageStock + */ + private $getQtyForNotManageStock; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var ManageStockCondition + */ + private $manageStockCondition; + + /** + * GetStockIndexDump constructor + * + * @param StockIndexTableNameResolverInterface $stockIndexTableNameResolver + * @param ResourceConnection $resourceConnection + * @param NotManageStockCondition $notManageStockCondition + * @param ManageStockCondition $manageStockCondition + * @param GetQtyForNotManageStock $getQtyForNotManageStock + * @param LoggerInterface $logger + */ + public function __construct( + StockIndexTableNameResolverInterface $stockIndexTableNameResolver, + ResourceConnection $resourceConnection, + NotManageStockCondition $notManageStockCondition, + ManageStockCondition $manageStockCondition, + GetQtyForNotManageStock $getQtyForNotManageStock, + LoggerInterface $logger + ) { + $this->stockIndexTableNameResolver = $stockIndexTableNameResolver; + $this->resourceConnection = $resourceConnection; + $this->notManageStockCondition = $notManageStockCondition; + $this->manageStockCondition = $manageStockCondition; + $this->getQtyForNotManageStock = $getQtyForNotManageStock; + $this->logger = $logger; + } + + /** + * Provides sku and qty of products dumping them from stock index table + * + * @param int $websiteId + * @param int $stockId + * @return array + * @throws LocalizedException + */ + public function execute(int $websiteId, int $stockId): array + { + $this->connection = $this->resourceConnection->getConnection(); + $select = $this->connection->select(); + try { + $select->union([ + $this->getStockItemSelect($websiteId), + $this->getStockIndexSelect($websiteId, $stockId) + ]); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), $e->getTrace()); + throw new LocalizedException(__('Something went wrong. Export couldn\'t be executed, See log files for error details')); + } + + return $this->connection->fetchAll($select); + } + + /** + * Provides stock select + * + * @param int $websiteId + * @param int $stockId + * @return Select + */ + private function getStockIndexSelect(int $websiteId, int $stockId): Select + { + $stockIndexTableName = $this->resourceConnection + ->getTableName($this->stockIndexTableNameResolver->execute($stockId)); + + $legacyStockItemTable = $this->resourceConnection + ->getTableName('cataloginventory_stock_item'); + $productEntityTable = $this->resourceConnection + ->getTableName('catalog_product_entity'); + $productWebsiteTable = $this->resourceConnection + ->getTableName('catalog_product_website'); + + $select = $this->connection->select(); + $ifExpression = ' + IF( + `product_entity`.`type_id` IN ( + \'' . Configurable::TYPE_CODE . '\', + \'' . Type::TYPE_BUNDLE . '\', + \'' . Grouped::TYPE_CODE . '\'), + NULL, + `quantity` + )'; + $select->from( + ['stock_index' => $stockIndexTableName], + [ + 'qty' => new \Zend_Db_Expr($ifExpression), + 'is_salable' => 'is_salable', + 'sku' => 'sku' + ] + )->join( + ['product_entity' => $productEntityTable], + 'product_entity.sku=stock_index.sku', + '' + )->join( + ['legacy_stock_item' => $legacyStockItemTable], + 'legacy_stock_item.product_id = product_entity.entity_id', + '' + )->join( + ['prod_website' => $productWebsiteTable], + 'legacy_stock_item.product_id = prod_website.product_id', + '' + )->where( + $this->manageStockCondition->execute($select) + )->where( + 'prod_website.website_id = ?', + $websiteId + ); + + return $select; + } + + /** + * Provides stock item select + * + * @param int $websiteId + * @return Select + */ + private function getStockItemSelect(int $websiteId): Select + { + $legacyStockItemTable = $this->resourceConnection + ->getTableName('cataloginventory_stock_item'); + $productEntityTable = $this->resourceConnection + ->getTableName('catalog_product_entity'); + $select = $this->connection->select(); + $getQtyForNotManageStock = $this->getQtyForNotManageStock->execute(); + if ($getQtyForNotManageStock === null) { + $getQtyForNotManageStock = 'NULL'; + } + $ifExpression = ' + IF( + `product_entity`.`type_id` IN ( + \'' . Configurable::TYPE_CODE . '\', + \'' . Type::TYPE_BUNDLE . '\', + \'' . Grouped::TYPE_CODE . '\'), + NULL, + ' . $getQtyForNotManageStock . ' + )'; + $select->from( + ['legacy_stock_item' => $legacyStockItemTable], + ['qty' =>new \Zend_Db_Expr($ifExpression), + new \Zend_Db_Expr('"1" as is_salable')] + )->join( + ['product_entity' => $productEntityTable], + 'legacy_stock_item.product_id = product_entity.entity_id', + ['sku'] + )->join( + ['pr_web' => 'catalog_product_website'], + 'legacy_stock_item.product_id = pr_web.product_id', + '' + )->where( + $this->notManageStockCondition->execute($select) + )->where( + 'pr_web.website_id = ?', + $websiteId + ); + + return $select; + } +} diff --git a/InventoryExportStock/README.md b/InventoryExportStock/README.md new file mode 100644 index 000000000000..57949a0c5da7 --- /dev/null +++ b/InventoryExportStock/README.md @@ -0,0 +1,16 @@ +# InventoryExportStock module + +The `InventoryExportStock` module provides aggregated stock export functionality. + +This module is part of the new inventory infrastructure. The +[Inventory Management overview](https://devdocs.magento.com/guides/v2.3/inventory/index.html) +describes the MSI (Multi-Source Inventory) project in more detail. + +## Installation details + +This module is installed as part of Magento Open Source. It may be disabled if the Inventory Management UI +is provided by a 3rd-party system or if you run a headless version of Magento. + +## Extension points and service contracts + +There are no extension points or service contracts for this module. diff --git a/InventoryExportStock/composer.json b/InventoryExportStock/composer.json new file mode 100644 index 000000000000..8e69e99aa7ca --- /dev/null +++ b/InventoryExportStock/composer.json @@ -0,0 +1,32 @@ +{ + "name": "magento/module-inventory-export-stock", + "description": "N/A", + "require": { + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-inventory-api": "*", + "magento/module-inventory-export-stock-api": "*", + "magento/module-inventory-sales-api": "*", + "magento/module-inventory-sales": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-inventory-indexer": "*", + "magento/module-inventory-configuration": "*", + "magento/module-inventory-configuration-api": "*", + "magento/module-configurable-product": "*", + "magento/module-grouped-product": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\InventoryExportStock\\": "" + } + } +} diff --git a/InventoryExportStock/etc/di.xml b/InventoryExportStock/etc/di.xml new file mode 100644 index 000000000000..0918b420394b --- /dev/null +++ b/InventoryExportStock/etc/di.xml @@ -0,0 +1,23 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <preference for="Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterface" + type="Magento\InventoryExportStock\Model\ExportStockSalableQtySearchResult"/> + <preference for="Magento\InventoryExportStockApi\Api\ExportStockSalableQtyInterface" + type="Magento\InventoryExportStock\Model\ExportStockSalableQty"/> + <preference for="Magento\InventoryExportStockApi\Api\ExportStockIndexDataInterface" + type="Magento\InventoryExportStock\Model\ExportStockIndexData"/> + <preference for="Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterface" + type="Magento\InventoryExportStock\Model\ProductStockIndexData"/> + <type name="Magento\InventoryExportStock\Model\GetQtyForNotManageStock"> + <arguments> + <argument name="qtyForNotManageStock" xsi:type="null"/> + </arguments> + </type> +</config> diff --git a/InventoryExportStock/etc/module.xml b/InventoryExportStock/etc/module.xml new file mode 100644 index 000000000000..700fcde6765a --- /dev/null +++ b/InventoryExportStock/etc/module.xml @@ -0,0 +1,24 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_InventoryExportStock" setup_version="1.0.0"> + <sequence> + <module name="Magento_Catalog"/> + <module name="Magento_ConfigurableProduct"/> + <module name="Magento_GroupedProduct"/> + <module name="Magento_InventoryApi"/> + <module name="Magento_InventoryIndexer"/> + <module name="Magento_InventoryCatalogApi"/> + <module name="Magento_InventoryConfigurationApi"/> + <module name="Magento_InventoryExportStockApi"/> + <module name="Magento_InventorySalesApi"/> + <module name="Magento_InventorySales"/> + </sequence> + </module> +</config> diff --git a/InventoryExportStock/registration.php b/InventoryExportStock/registration.php new file mode 100644 index 000000000000..587a5f886339 --- /dev/null +++ b/InventoryExportStock/registration.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_InventoryExportStock', + __DIR__ +); diff --git a/InventoryExportStockApi/Api/Data/ExportStockSalableQtySearchResultInterface.php b/InventoryExportStockApi/Api/Data/ExportStockSalableQtySearchResultInterface.php new file mode 100644 index 000000000000..7b2237cbf9a6 --- /dev/null +++ b/InventoryExportStockApi/Api/Data/ExportStockSalableQtySearchResultInterface.php @@ -0,0 +1,30 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStockApi\Api\Data; + +use Magento\Framework\Api\SearchResultsInterface; + +/** + * Interface for ExportStockSalableQtySearchResult + * @api + */ +interface ExportStockSalableQtySearchResultInterface extends SearchResultsInterface +{ + /** + * {@inheritdoc} + */ + public function getItems(); + + /** + * Set stock data array + * + * @param array $items + * @return $this + */ + public function setItems(array $items); +} diff --git a/InventoryExportStockApi/Api/Data/ProductStockIndexDataInterface.php b/InventoryExportStockApi/Api/Data/ProductStockIndexDataInterface.php new file mode 100644 index 000000000000..feb898491e25 --- /dev/null +++ b/InventoryExportStockApi/Api/Data/ProductStockIndexDataInterface.php @@ -0,0 +1,66 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStockApi\Api\Data; + +/** + * Class ExportStockIndexDataResultInterface for result Inventory stock index dump export + * + * @api + */ +interface ProductStockIndexDataInterface +{ + public const QTY = 'qty'; + public const IS_SALABLE = 'is_salable'; + public const SKU = 'sku'; + + /** + * Provides product SKU + * + * @return string + */ + public function getSku(): string; + + /** + * Provides product QTY + * + * @return float + */ + public function getQty(): float; + + /** + * Provides product is salable flag + * + * @return bool + */ + public function getIsSalable(): bool; + + /** + * Sets SKU + * + * @param string $sku + * @return void + */ + public function setSku(string $sku): void; + + /** + * Sets QTY + * + * @param float $qty + * @return void + */ + public function setQty(float $qty): void; + + /** + * Sets is salable flag + * + * @param bool $isSalable + * @return void + */ + public function setIsSalable(bool $isSalable): void; + +} diff --git a/InventoryExportStockApi/Api/ExportStockIndexDataInterface.php b/InventoryExportStockApi/Api/ExportStockIndexDataInterface.php new file mode 100644 index 000000000000..0c15ad204d29 --- /dev/null +++ b/InventoryExportStockApi/Api/ExportStockIndexDataInterface.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStockApi\Api; + +/** + * Interface for ExportStockIndexData which provides stock index export + * @api + */ +interface ExportStockIndexDataInterface +{ + /** + * Provides stock index export from inventory_stock_% table + * + * @param string $websiteCode + * @return \Magento\InventoryExportStockApi\Api\Data\ProductStockIndexDataInterface[] + */ + public function execute(string $websiteCode): array; +} diff --git a/InventoryExportStockApi/Api/ExportStockSalableQtyInterface.php b/InventoryExportStockApi/Api/ExportStockSalableQtyInterface.php new file mode 100644 index 000000000000..b0b2b3d55ff3 --- /dev/null +++ b/InventoryExportStockApi/Api/ExportStockSalableQtyInterface.php @@ -0,0 +1,27 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryExportStockApi\Api; + +/** + * Interface for ExportStockSalableQty provides product's salable qty information by search criteria + * @api + */ +interface ExportStockSalableQtyInterface +{ + /** + * Provides stock export data + * + * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + * @param string $websiteCode + * @return \Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterface + */ + public function execute( + \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria, + string $websiteCode + ): \Magento\InventoryExportStockApi\Api\Data\ExportStockSalableQtySearchResultInterface; +} diff --git a/InventoryExportStockApi/LICENSE.txt b/InventoryExportStockApi/LICENSE.txt new file mode 100644 index 000000000000..49525fd99da9 --- /dev/null +++ b/InventoryExportStockApi/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/InventoryExportStockApi/LICENSE_AFL.txt b/InventoryExportStockApi/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/InventoryExportStockApi/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/InventoryExportStockApi/README.md b/InventoryExportStockApi/README.md new file mode 100644 index 000000000000..acc559b68bca --- /dev/null +++ b/InventoryExportStockApi/README.md @@ -0,0 +1,16 @@ +# InventoryExportStockApi module + +The `InventoryExportStockApi` module provides provides aggregated stock export functionality api. + +This module is part of the new inventory infrastructure. The +[Inventory Management overview](https://devdocs.magento.com/guides/v2.3/inventory/index.html) +describes the MSI (Multi-Source Inventory) project in more detail. + +## Installation details + +This module is installed as part of Magento Open Source. It may be disabled if the Inventory Management UI +is provided by a 3rd-party system or if you run a headless version of Magento. + +## Extension points and service contracts + +There are no extension points or service contracts for this module. diff --git a/InventoryExportStockApi/composer.json b/InventoryExportStockApi/composer.json new file mode 100644 index 000000000000..ec08f5717551 --- /dev/null +++ b/InventoryExportStockApi/composer.json @@ -0,0 +1,21 @@ +{ + "name": "magento/module-inventory-export-stock-api", + "description": "N/A", + "require": { + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\InventoryExportStockApi\\": "" + } + } +} diff --git a/InventoryExportStockApi/etc/module.xml b/InventoryExportStockApi/etc/module.xml new file mode 100644 index 000000000000..7d6f4cd1c561 --- /dev/null +++ b/InventoryExportStockApi/etc/module.xml @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_InventoryExportStockApi" setup_version="1.0.0"/> +</config> diff --git a/InventoryExportStockApi/etc/webapi.xml b/InventoryExportStockApi/etc/webapi.xml new file mode 100644 index 000000000000..225dbdf5f9df --- /dev/null +++ b/InventoryExportStockApi/etc/webapi.xml @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd"> + <route url="/V1/inventory/export-stock-salable-qty" method="GET"> + <service class="Magento\InventoryExportStockApi\Api\ExportStockSalableQtyInterface" method="execute"/> + <resources> + <resource ref="Magento_InventoryApi::stock"/> + </resources> + </route> + <route url="/V1/inventory/dump-stock-index-data" method="GET"> + <service class="Magento\InventoryExportStockApi\Api\ExportStockIndexDataInterface" method="execute"/> + <resources> + <resource ref="Magento_InventoryApi::stock"/> + </resources> + </route> +</routes> diff --git a/InventoryExportStockApi/registration.php b/InventoryExportStockApi/registration.php new file mode 100644 index 000000000000..daad5737aa1e --- /dev/null +++ b/InventoryExportStockApi/registration.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_InventoryExportStockApi', + __DIR__ +); diff --git a/InventoryGraphQl/LICENSE.txt b/InventoryGraphQl/LICENSE.txt new file mode 100644 index 000000000000..49525fd99da9 --- /dev/null +++ b/InventoryGraphQl/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/InventoryGraphQl/LICENSE_AFL.txt b/InventoryGraphQl/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/InventoryGraphQl/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/InventoryGraphQl/Model/Resolver/OnlyXLeftInStockResolver.php b/InventoryGraphQl/Model/Resolver/OnlyXLeftInStockResolver.php new file mode 100644 index 000000000000..b24393c1c98e --- /dev/null +++ b/InventoryGraphQl/Model/Resolver/OnlyXLeftInStockResolver.php @@ -0,0 +1,100 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryGraphQl\Model\Resolver; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; +use Magento\InventoryConfigurationApi\Api\GetStockItemConfigurationInterface; +use Magento\InventoryConfigurationApi\Exception\SkuIsNotAssignedToStockException; +use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface; + +/** + * @inheritdoc + */ +class OnlyXLeftInStockResolver implements ResolverInterface +{ + /** + * @var GetProductSalableQtyInterface + */ + private $getProductSalableQty; + + /** + * @var GetStockIdForCurrentWebsite + */ + private $getStockIdForCurrentWebsite; + + /** + * @var GetStockItemConfigurationInterface + */ + private $getStockItemConfiguration; + + /** + * @param GetProductSalableQtyInterface $getProductSalableQty + * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite + * @param GetStockItemConfigurationInterface $getStockItemConfiguration + */ + public function __construct( + GetProductSalableQtyInterface $getProductSalableQty, + GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite, + GetStockItemConfigurationInterface $getStockItemConfiguration + ) { + $this->getProductSalableQty = $getProductSalableQty; + $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite; + $this->getStockItemConfiguration = $getStockItemConfiguration; + } + + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + /* @var $product ProductInterface */ + $product = $value['model']; + $onlyXLeftQty = $this->getOnlyXLeftQty($product->getSku()); + + return $onlyXLeftQty; + } + + /** + * Get quantity of a specified product when equals or lower then configured threshold. + * + * @param string $sku + * @return null|float + * @throws SkuIsNotAssignedToStockException + * @throws LocalizedException + */ + private function getOnlyXLeftQty(string $sku): ?float + { + $stockId = $this->getStockIdForCurrentWebsite->execute(); + $stockItemConfiguration = $this->getStockItemConfiguration->execute($sku, $stockId); + + $thresholdQty = $stockItemConfiguration->getStockThresholdQty(); + if ($thresholdQty === 0) { + return null; + } + + try { + $productSalableQty = $this->getProductSalableQty->execute($sku, $stockId); + $stockLeft = $productSalableQty - $stockItemConfiguration->getMinQty(); + + if ($productSalableQty > 0 && $stockLeft <= $thresholdQty) { + return (float)$stockLeft; + } + } catch (InputException | LocalizedException $e) { + // this is expected behavior because ex. Group product doesn't have own quantity + return null; + } + + return null; + } +} diff --git a/InventoryGraphQl/Model/Resolver/StockStatusProvider.php b/InventoryGraphQl/Model/Resolver/StockStatusProvider.php new file mode 100644 index 000000000000..1378dac1f00f --- /dev/null +++ b/InventoryGraphQl/Model/Resolver/StockStatusProvider.php @@ -0,0 +1,62 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryGraphQl\Model\Resolver; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; +use Magento\InventorySalesApi\Api\IsProductSalableInterface; + +/** + * @inheritdoc + */ +class StockStatusProvider implements ResolverInterface +{ + /** + * @var IsProductSalableInterface + */ + private $isProductSalable; + + /** + * @var GetStockIdForCurrentWebsite + */ + private $getStockIdForCurrentWebsite; + + /** + * @param IsProductSalableInterface $isProductSalable + * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite + */ + public function __construct( + IsProductSalableInterface $isProductSalable, + GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite + ) { + $this->isProductSalable = $isProductSalable; + $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite; + } + + /** + * @inheritdoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!array_key_exists('model', $value) || !$value['model'] instanceof ProductInterface) { + throw new LocalizedException(__('"model" value should be specified')); + } + + /* @var $product ProductInterface */ + $product = $value['model']; + + $stockId = $this->getStockIdForCurrentWebsite->execute(); + $isProductSalable = $this->isProductSalable->execute($product->getSku(), $stockId); + + return $isProductSalable ? 'IN_STOCK' : 'OUT_OF_STOCK'; + } +} diff --git a/InventoryGraphQl/README.md b/InventoryGraphQl/README.md new file mode 100644 index 000000000000..493638348325 --- /dev/null +++ b/InventoryGraphQl/README.md @@ -0,0 +1,16 @@ +# InventoryGraphQl module + +The `InventoryGraphQl` provides type information for the GraphQl module + to generate inventory stock fields for product information endpoints. + +This module is part of the new inventory infrastructure. The +[Inventory Management overview](https://devdocs.magento.com/guides/v2.3/inventory/index.html) +describes the MSI (Multi-Source Inventory) project in more detail. + +## Installation details + +This module is installed as part of Magento Open Source. It cannot be deleted or disabled. + +## Extension points and service contracts + +There are no extension points or service contracts for this module. diff --git a/InventoryGraphQl/composer.json b/InventoryGraphQl/composer.json new file mode 100644 index 000000000000..bc0bb2bb56d4 --- /dev/null +++ b/InventoryGraphQl/composer.json @@ -0,0 +1,25 @@ +{ + "name": "magento/module-inventory-graph-ql", + "description": "N/A", + "type": "magento2-module", + "require": { + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-inventory-catalog": "*", + "magento/module-inventory-configuration-api": "*", + "magento/module-inventory-sales-api": "*" + }, + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\InventoryGraphQl\\": "" + } + } +} diff --git a/InventoryGraphQl/etc/module.xml b/InventoryGraphQl/etc/module.xml new file mode 100644 index 000000000000..891e15bef9e4 --- /dev/null +++ b/InventoryGraphQl/etc/module.xml @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_InventoryGraphQl"> + <sequence> + <module name="Magento_Catalog"/> + <module name="Magento_InventoryCatalog"/> + <module name="Magento_InventoryConfigurationApi"/> + <module name="Magento_InventorySalesApi"/> + <module name="Magento_CatalogInventoryGraphQl"/> + </sequence> + </module> +</config> diff --git a/InventoryGraphQl/etc/schema.graphqls b/InventoryGraphQl/etc/schema.graphqls new file mode 100644 index 000000000000..ce7e66457011 --- /dev/null +++ b/InventoryGraphQl/etc/schema.graphqls @@ -0,0 +1,12 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +interface ProductInterface { + only_x_left_in_stock: Float @doc(description: "Product stock only x left count") @resolver(class: "Magento\\InventoryGraphQl\\Model\\Resolver\\OnlyXLeftInStockResolver") + stock_status: ProductStockStatus @doc(description: "Stock status of the product") @resolver(class: "Magento\\InventoryGraphQl\\Model\\Resolver\\StockStatusProvider") +} + +enum ProductStockStatus @doc(description: "This enumeration states whether a product stock status is in stock or out of stock") { + IN_STOCK + OUT_OF_STOCK +} diff --git a/InventoryGraphQl/registration.php b/InventoryGraphQl/registration.php new file mode 100644 index 000000000000..7e6cd8606b6c --- /dev/null +++ b/InventoryGraphQl/registration.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_InventoryGraphQl', + __DIR__ +); diff --git a/InventoryInStorePickup/Model/Address.php b/InventoryInStorePickup/Model/Address.php new file mode 100644 index 000000000000..1c60be197149 --- /dev/null +++ b/InventoryInStorePickup/Model/Address.php @@ -0,0 +1,87 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\InventoryInStorePickupApi\Api\Data\AddressInterface; + +/** + * {@inheritdoc} + * @codeCoverageIgnore + */ +class Address implements AddressInterface +{ + /** + * @var string + */ + private $country; + + /** + * @var string|null + */ + private $postcode; + + /** + * @var string|null + */ + private $region; + + /** + * @var string|null + */ + private $city; + + /** + * @param string $country + * @param string|null $postcode + * @param string|null $region + * @param string|null $city + */ + public function __construct( + string $country, + ?string $postcode = null, + ?string $region = null, + ?string $city = null + ) { + $this->country = $country; + $this->postcode = $postcode; + $this->region = $region; + $this->city = $city; + } + + /** + * @inheritdoc + */ + public function getCountry(): string + { + return $this->country; + } + + /** + * @inheritdoc + */ + public function getPostcode(): ?string + { + return $this->postcode; + } + + /** + * @inheritdoc + */ + public function getRegion(): ?string + { + return $this->region; + } + + /** + * @inheritdoc + */ + public function getCity(): ?string + { + return $this->city; + } +} diff --git a/InventoryInStorePickup/Model/Convert/AddressToSourceSelectionAddress.php b/InventoryInStorePickup/Model/Convert/AddressToSourceSelectionAddress.php new file mode 100644 index 000000000000..d5998cf8f8cc --- /dev/null +++ b/InventoryInStorePickup/Model/Convert/AddressToSourceSelectionAddress.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\Convert; + +use Magento\InventoryInStorePickupApi\Api\Data\AddressInterface as PickupLocationsRequestAddressInterface; +use Magento\InventorySourceSelectionApi\Api\Data\AddressInterface as SourceSelectionAddressInterface; +use Magento\InventorySourceSelectionApi\Api\Data\AddressInterfaceFactory; + +/** + * Create Source Selection Address based on Pickup Locations Address request. + */ +class AddressToSourceSelectionAddress +{ + /** + * @var AddressInterfaceFactory + */ + private $addressInterfaceFactory; + + /** + * @param AddressInterfaceFactory $addressInterfaceFactory + */ + public function __construct(AddressInterfaceFactory $addressInterfaceFactory) + { + $this->addressInterfaceFactory = $addressInterfaceFactory; + } + + /** + * @param PickupLocationsRequestAddressInterface $address + * + * @return SourceSelectionAddressInterface + */ + public function execute(PickupLocationsRequestAddressInterface $address): SourceSelectionAddressInterface + { + $data = [ + 'country' => $address->getCountry(), + 'postcode' => $address->getPostcode() ?? '', + 'region' => $address->getRegion() ?? '', + 'city' => $address->getCity() ??'', + 'street' => '' + ]; + + return $this->addressInterfaceFactory->create($data); + } +} diff --git a/InventoryInStorePickup/Model/DistanceProvider/Offline/GetNearbySourcesByPostcode.php b/InventoryInStorePickup/Model/DistanceProvider/Offline/GetNearbySourcesByPostcode.php deleted file mode 100644 index 2708f627a46c..000000000000 --- a/InventoryInStorePickup/Model/DistanceProvider/Offline/GetNearbySourcesByPostcode.php +++ /dev/null @@ -1,107 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\InventoryInStorePickup\Model\DistanceProvider\Offline; - -use Magento\Framework\App\ResourceConnection; -use Magento\Framework\Exception\NoSuchEntityException; -use Magento\InventoryApi\Api\Data\SourceInterface; -use Magento\InventoryApi\Api\Data\SourceInterfaceFactory; -use Magento\InventoryInStorePickupApi\Api\GetNearbySourcesByPostcodeInterface; - -/** - * Find nearest Inventory Sources by postal code using Haversine formula (Great Circle Distance) database query. - */ -class GetNearbySourcesByPostcode implements GetNearbySourcesByPostcodeInterface -{ - private const EARTH_RADIUS_KM = 6372.797; - - /** - * @var ResourceConnection - */ - private $resourceConnection; - - /** - * @var SourceInterfaceFactory - */ - private $sourceInterfaceFactory; - - /** - * @param ResourceConnection $resourceConnection - * @param SourceInterfaceFactory $sourceInterfaceFactory - */ - public function __construct( - ResourceConnection $resourceConnection, - SourceInterfaceFactory $sourceInterfaceFactory - ) { - $this->resourceConnection = $resourceConnection; - $this->sourceInterfaceFactory = $sourceInterfaceFactory; - } - - /** - * {@inheritdoc} - * - * @throws - */ - public function execute(string $country, string $postcode, int $radius): array - { - $connection = $this->resourceConnection->getConnection(); - $tableName = $this->resourceConnection->getTableName('inventory_geoname'); - $sourceTable = $this->resourceConnection->getTableName('inventory_source'); - - $query = $connection->select()->from($tableName) - ->where('country_code = ?', $country) - ->where('postcode = ?', $postcode) - ->limit(1); - $row = $connection->fetchRow($query); - - if (!$row) { - throw new NoSuchEntityException( - __('Unknown postcode %1 in %2', $postcode, $country) - ); - } - - // Still here so the target postcode is valid - $lat = (float)$row['latitude']; - $lng = (float)$row['longitude']; - - // Build up a radial query - $query = $connection->select() - ->from($sourceTable) - ->columns(['*', $this->createDistanceColumn($lat, $lng) . ' AS distance']) - ->where(SourceInterface::ENABLED) - ->having('distance <= ?', $radius) - ->order('distance ASC'); - - $rows = $connection->fetchAll($query); - $results = []; - foreach ($rows as $row) { - $item = $this->sourceInterfaceFactory->create(['data' => $row]); - $results[] = $item; - } - - return $results; - } - - /** - * Construct DB query to calculate Great Circle Distance - * - * @param float $latitude - * @param float $longitude - * @return string - */ - private function createDistanceColumn(float $latitude, float $longitude): string - { - return '(' . self::EARTH_RADIUS_KM . ' * ACOS(' - . 'COS(RADIANS(' . $latitude . ')) * ' - . 'COS(RADIANS(latitude)) * ' - . 'COS(RADIANS(longitude) - RADIANS(' . $longitude . ')) + ' - . 'SIN(RADIANS(' . $latitude . ')) * ' - . 'SIN(RADIANS(latitude))' - . '))'; - } -} diff --git a/InventoryInStorePickup/Model/GetNearbyPickupLocations.php b/InventoryInStorePickup/Model/GetNearbyPickupLocations.php new file mode 100644 index 000000000000..b2c93e565e33 --- /dev/null +++ b/InventoryInStorePickup/Model/GetNearbyPickupLocations.php @@ -0,0 +1,129 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventoryApi\Api\Data\SourceInterface; +use Magento\InventoryApi\Api\Data\StockSourceLinkInterface; +use Magento\InventoryApi\Api\GetStockSourceLinksInterface; +use Magento\InventoryApi\Api\SourceRepositoryInterface; +use Magento\InventoryDistanceBasedSourceSelectionApi\Api\GetLatLngFromAddressInterface; +use Magento\InventoryInStorePickup\Model\Convert\AddressToSourceSelectionAddress; +use Magento\InventoryInStorePickup\Model\ResourceModel\Source\GetDistanceOrderedSourceCodes; +use Magento\InventoryInStorePickupApi\Api\Data\AddressInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\InventoryInStorePickupApi\Api\GetNearbyPickupLocationsInterface; +use Magento\InventoryInStorePickupApi\Model\Mapper; + +/** + * @inheritdoc + */ +class GetNearbyPickupLocations implements GetNearbyPickupLocationsInterface +{ + /** + * @var Mapper + */ + private $mapper; + + /** + * @var AddressToSourceSelectionAddress + */ + private $addressToSourceSelectionAddress; + + /** + * @var GetLatLngFromAddressInterface + */ + private $getLatLngFromAddress; + + /** + * @var GetDistanceOrderedSourceCodes + */ + private $getDistanceOrderedSourceCodes; + + /** + * @var GetStockSourceLinksInterface + */ + private $getStockSourceLinks; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var SourceRepositoryInterface + */ + private $sourceRepository; + + /** + * @param Mapper $mapper + * @param AddressToSourceSelectionAddress $addressToSourceSelectionAddress + * @param GetLatLngFromAddressInterface $getLatLngFromAddress + * @param GetDistanceOrderedSourceCodes $getDistanceOrderedSourceCodes + * @param GetStockSourceLinksInterface $getStockSourceLinks + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param SourceRepositoryInterface $sourceRepository + */ + public function __construct( + Mapper $mapper, + AddressToSourceSelectionAddress $addressToSourceSelectionAddress, + GetLatLngFromAddressInterface $getLatLngFromAddress, + GetDistanceOrderedSourceCodes $getDistanceOrderedSourceCodes, + GetStockSourceLinksInterface $getStockSourceLinks, + SearchCriteriaBuilder $searchCriteriaBuilder, + SourceRepositoryInterface $sourceRepository + ) { + $this->mapper = $mapper; + $this->addressToSourceSelectionAddress = $addressToSourceSelectionAddress; + $this->getLatLngFromAddress = $getLatLngFromAddress; + $this->getDistanceOrderedSourceCodes = $getDistanceOrderedSourceCodes; + $this->getStockSourceLinks = $getStockSourceLinks; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->sourceRepository = $sourceRepository; + } + + /** + * @inheritdoc + */ + public function execute(AddressInterface $address, int $radius, int $stockId): array + { + $sourceSelectionAddress = $this->addressToSourceSelectionAddress->execute($address); + $latLng = $this->getLatLngFromAddress->execute($sourceSelectionAddress); + + $codes = $this->getDistanceOrderedSourceCodes->execute($latLng, $radius); + + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(StockSourceLinkInterface::STOCK_ID, $stockId) + ->addFilter(StockSourceLinkInterface::SOURCE_CODE, $codes, 'in') + ->create(); + $searchResult = $this->getStockSourceLinks->execute($searchCriteria); + $stockCodes = []; + + foreach ($searchResult->getItems() as $item) { + $stockCodes[] = $item->getSourceCode(); + } + + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(SourceInterface::SOURCE_CODE, $stockCodes, 'in') + ->addFilter(PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE, true) + ->create(); + $searchResult = $this->sourceRepository->getList($searchCriteria); + + $results = []; + + foreach ($searchResult->getItems() as $source) { + $results[] = $this->mapper->map($source); + } + + usort($results, function (PickupLocationInterface $left, PickupLocationInterface $right) use ($codes) { + return array_search($left->getSourceCode(), $codes) <=> array_search($right->getSourceCode(), $codes); + }); + + return $results; + } +} diff --git a/InventoryInStorePickup/Model/GetPickupLocationsAssignedToStockOrderedByPriority.php b/InventoryInStorePickup/Model/GetPickupLocationsAssignedToStockOrderedByPriority.php new file mode 100644 index 000000000000..6fc79bd2301e --- /dev/null +++ b/InventoryInStorePickup/Model/GetPickupLocationsAssignedToStockOrderedByPriority.php @@ -0,0 +1,59 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface; +use Magento\InventoryInStorePickupApi\Model\Mapper; +use Magento\InventoryInStorePickupApi\Api\GetPickupLocationsAssignedToStockOrderedByPriorityInterface; + +/** + * @inheritdoc + */ +class GetPickupLocationsAssignedToStockOrderedByPriority implements GetPickupLocationsAssignedToStockOrderedByPriorityInterface +{ + /** + * @var GetSourcesAssignedToStockOrderedByPriorityInterface + */ + private $getSourcesAssignedToStockOrderedByPriority; + + /** + * @var Mapper + */ + private $mapper; + + /** + * @param GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority + * @param Mapper $mapper + */ + public function __construct( + GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority, + Mapper $mapper + ) { + $this->getSourcesAssignedToStockOrderedByPriority = $getSourcesAssignedToStockOrderedByPriority; + $this->mapper = $mapper; + } + + /** + * @inheritdoc + * @throws LocalizedException + */ + public function execute(int $stockId): array + { + $sources = $this->getSourcesAssignedToStockOrderedByPriority->execute($stockId); + + $result = []; + foreach ($sources as $source) { + if ($source->getExtensionAttributes() && $source->getExtensionAttributes()->getIsPickupLocationActive()) { + $result[] = $this->mapper->map($source); + } + } + + return $result; + } +} diff --git a/InventoryInStorePickup/Model/IsOrderReadyForPickup.php b/InventoryInStorePickup/Model/IsOrderReadyForPickup.php new file mode 100644 index 000000000000..d8a3482a43f2 --- /dev/null +++ b/InventoryInStorePickup/Model/IsOrderReadyForPickup.php @@ -0,0 +1,69 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\InventoryInStorePickup\Model\Order\IsFulfillable; +use Magento\InventoryInStorePickupApi\Api\IsOrderReadyForPickupInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; + +/** + * Check if order can be shipped and the pickup location has enough QTY + */ +class IsOrderReadyForPickup implements IsOrderReadyForPickupInterface +{ + /** + * @var IsFulfillable + */ + private $isFulfillable; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @param IsFulfillable $isFulfillable + * @param OrderRepositoryInterface $orderRepository + */ + public function __construct( + IsFulfillable $isFulfillable, + OrderRepositoryInterface $orderRepository + ) { + $this->isFulfillable = $isFulfillable; + $this->orderRepository = $orderRepository; + } + + /** + * @param int $orderId + * + * @return bool + */ + public function execute(int $orderId): bool + { + $order = $this->orderRepository->get($orderId); + + return $this->canShip($order) && $this->isFulfillable->execute($order); + } + + /** + * Retrieve order shipment availability. + * + * @param OrderInterface $order + * @return bool + */ + private function canShip(OrderInterface $order): bool + { + if ($order instanceof Order) { + return $order->canShip(); + } + + return true; + } +} diff --git a/InventoryInStorePickup/Model/NotifyOrderIsReadyForPickup.php b/InventoryInStorePickup/Model/NotifyOrderIsReadyForPickup.php new file mode 100644 index 000000000000..b45e61c3a88f --- /dev/null +++ b/InventoryInStorePickup/Model/NotifyOrderIsReadyForPickup.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryInStorePickup\Model\Order\Email\ReadyForPickupNotifier; +use Magento\InventoryInStorePickupApi\Api\IsOrderReadyForPickupInterface; +use Magento\InventoryInStorePickupApi\Api\NotifyOrderIsReadyForPickupInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\ShipOrderInterface; + +/** + * Send an email to the customer and ship the order to reserve (deduct) pickup location`s QTY. + */ +class NotifyOrderIsReadyForPickup implements NotifyOrderIsReadyForPickupInterface +{ + /** + * @var IsOrderReadyForPickupInterface + */ + private $isOrderReadyForPickup; + + /** + * @var ShipOrderInterface + */ + private $shipOrder; + + /** + * @var ReadyForPickupNotifier + */ + private $emailNotifier; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @param IsOrderReadyForPickupInterface $isOrderReadyForPickup + * @param ShipOrderInterface $shipOrder + * @param ReadyForPickupNotifier $emailNotifier + * @param OrderRepositoryInterface $orderRepository + */ + public function __construct( + IsOrderReadyForPickupInterface $isOrderReadyForPickup, + ShipOrderInterface $shipOrder, + ReadyForPickupNotifier $emailNotifier, + OrderRepositoryInterface $orderRepository + ) { + $this->isOrderReadyForPickup = $isOrderReadyForPickup; + $this->shipOrder = $shipOrder; + $this->emailNotifier = $emailNotifier; + $this->orderRepository = $orderRepository; + } + + /** + * @inheritdoc + */ + public function execute(int $orderId): void + { + if (!$this->isOrderReadyForPickup->execute($orderId)) { + throw new LocalizedException(__('The order is not ready for pickup')); + } + + /** @noinspection PhpParamsInspection */ + $this->emailNotifier->notify($this->orderRepository->get($orderId)); + + /* TODO: add order comment? */ + + $this->shipOrder->execute($orderId); + } +} diff --git a/InventoryInStorePickup/Model/Order/Email/Container/ReadyForPickupIdentity.php b/InventoryInStorePickup/Model/Order/Email/Container/ReadyForPickupIdentity.php new file mode 100644 index 000000000000..71af685545b5 --- /dev/null +++ b/InventoryInStorePickup/Model/Order/Email/Container/ReadyForPickupIdentity.php @@ -0,0 +1,84 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\Order\Email\Container; + +use Magento\Sales\Model\Order\Email\Container\Container; +use Magento\Store\Model\ScopeInterface; + +/** + * @inheritdoc + */ +class ReadyForPickupIdentity extends Container +{ + /** + * Configuration paths + */ + private const XML_PATH_EMAIL_COPY_METHOD = 'storepickup_email/order_ready_for_pickup/copy_method'; + private const XML_PATH_EMAIL_COPY_TO = 'storepickup_email/order_ready_for_pickup/copy_to'; + private const XML_PATH_EMAIL_IDENTITY = 'storepickup_email/order_ready_for_pickup/identity'; + private const XML_PATH_EMAIL_GUEST_TEMPLATE = 'storepickup_email/order_ready_for_pickup/guest_template'; + private const XML_PATH_EMAIL_TEMPLATE = 'storepickup_email/order_ready_for_pickup/template'; + private const XML_PATH_EMAIL_ENABLED = 'storepickup_email/order_ready_for_pickup/enabled'; + + /** + * @inheritdoc + */ + public function isEnabled() + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_EMAIL_ENABLED, + ScopeInterface::SCOPE_STORE, + $this->getStore()->getStoreId() + ); + } + + /** + * @inheritdoc + */ + public function getEmailCopyTo() + { + $data = $this->getConfigValue(self::XML_PATH_EMAIL_COPY_TO, $this->getStore()->getStoreId()); + if (!empty($data)) { + return explode(',', $data); + } + + return false; + } + + /** + * @inheritdoc + */ + public function getCopyMethod() + { + return $this->getConfigValue(self::XML_PATH_EMAIL_COPY_METHOD, $this->getStore()->getStoreId()); + } + + /** + * @inheritdoc + */ + public function getGuestTemplateId() + { + return $this->getConfigValue(self::XML_PATH_EMAIL_GUEST_TEMPLATE, $this->getStore()->getStoreId()); + } + + /** + * @inheritdoc + */ + public function getTemplateId() + { + return $this->getConfigValue(self::XML_PATH_EMAIL_TEMPLATE, $this->getStore()->getStoreId()); + } + + /** + * @inheritdoc + */ + public function getEmailIdentity() + { + return $this->getConfigValue(self::XML_PATH_EMAIL_IDENTITY, $this->getStore()->getStoreId()); + } +} diff --git a/InventoryInStorePickup/Model/Order/Email/ReadyForPickupNotifier.php b/InventoryInStorePickup/Model/Order/Email/ReadyForPickupNotifier.php new file mode 100644 index 000000000000..0d4c7c6d42fd --- /dev/null +++ b/InventoryInStorePickup/Model/Order/Email/ReadyForPickupNotifier.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\Order\Email; + +use Magento\Sales\Model\AbstractNotifier; +use Magento\Sales\Model\Order\Email\Sender\OrderSender; +use Magento\Sales\Model\ResourceModel\Order\Status\History\CollectionFactory; +use Psr\Log\LoggerInterface as Logger; + +/** + * {@inheritdoc} + * + * TODO: remove this class with asynchronous mailing implementation + * @see https://github.com/magento-engcom/msi/issues/2160 + */ +class ReadyForPickupNotifier extends AbstractNotifier +{ + /** + * @var CollectionFactory + */ + protected $historyCollectionFactory; + + /** + * @var Logger + */ + protected $logger; + + /** + * @var OrderSender + */ + protected $sender; + + /** + * @param CollectionFactory $historyCollectionFactory + * @param Logger $logger + * @param ReadyForPickupSender $sender + */ + public function __construct( + CollectionFactory $historyCollectionFactory, + Logger $logger, + ReadyForPickupSender $sender + ) { + $this->historyCollectionFactory = $historyCollectionFactory; + $this->logger = $logger; + $this->sender = $sender; + } +} diff --git a/InventoryInStorePickup/Model/Order/Email/ReadyForPickupSender.php b/InventoryInStorePickup/Model/Order/Email/ReadyForPickupSender.php new file mode 100644 index 000000000000..8e86eacaaa34 --- /dev/null +++ b/InventoryInStorePickup/Model/Order/Email/ReadyForPickupSender.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\Order\Email; + +use Magento\Framework\DataObject; +use Magento\Framework\Event\ManagerInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Address\Renderer; +use Magento\Sales\Model\Order\Email\Container\IdentityInterface; +use Magento\Sales\Model\Order\Email\Container\Template; +use Magento\Sales\Model\Order\Email\Sender; +use Magento\Sales\Model\Order\Email\SenderBuilderFactory; +use Psr\Log\LoggerInterface; + +/** + * {@inheritdoc} + * + * TODO: refactor + * TODO: Implement asynchronous email sending + * @see https://github.com/magento-engcom/msi/issues/2160 + */ +class ReadyForPickupSender extends Sender +{ + /** + * @var ManagerInterface + */ + private $eventManager; + + /** + * @param Template $templateContainer + * @param IdentityInterface $identityContainer + * @param SenderBuilderFactory $senderBuilderFactory + * @param LoggerInterface $logger + * @param Renderer $addressRenderer + * @param ManagerInterface $eventManager + */ + public function __construct( + Template $templateContainer, + IdentityInterface $identityContainer, + SenderBuilderFactory $senderBuilderFactory, + LoggerInterface $logger, + Renderer $addressRenderer, + ManagerInterface $eventManager + ) { + parent::__construct($templateContainer, $identityContainer, $senderBuilderFactory, $logger, $addressRenderer); + + $this->eventManager = $eventManager; + } + + /** + * Send order-specific email. + * + * This method is not declared anywhere in parent/interface, but Magento calls it + * + * @param Order $order + * @return bool + */ + public function send(Order $order): bool + { + return $this->checkAndSend($order); + } + + /** + * Prepare email template with variables + * + * @param Order $order + * @return void + */ + protected function prepareTemplate(Order $order) + { + $transport = [ + 'order' => $order, + 'store' => $order->getStore(), + 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), + ]; + $transportObject = new DataObject($transport); + + /** + * Event argument `transport` is @deprecated. Use `transportObject` instead. + */ + $this->eventManager->dispatch( + 'email_ready_for_pickup_set_template_vars_before', + ['sender' => $this, 'transport' => $transportObject, 'transportObject' => $transportObject] + ); + + $this->templateContainer->setTemplateVars($transportObject->getData()); + + parent::prepareTemplate($order); + } +} diff --git a/InventoryInStorePickup/Model/Order/IsFulfillable.php b/InventoryInStorePickup/Model/Order/IsFulfillable.php new file mode 100644 index 000000000000..7053817c188f --- /dev/null +++ b/InventoryInStorePickup/Model/Order/IsFulfillable.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\Order; + +use Magento\Framework\Api\SearchCriteriaBuilderFactory; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; +use Magento\Sales\Api\Data\OrderInterface; + +/** + * Check if order can be fulfilled: if its pickup location has enough QTY + */ +class IsFulfillable +{ + /** + * @var SourceItemRepositoryInterface + */ + private $sourceItemRepository; + + /** + * @var SearchCriteriaBuilderFactory + */ + private $searchCriteriaBuilderFactory; + + /** + * @param SourceItemRepositoryInterface $sourceItemRepository + * @param SearchCriteriaBuilderFactory $searchCriteriaBuilder + */ + public function __construct( + SourceItemRepositoryInterface $sourceItemRepository, + SearchCriteriaBuilderFactory $searchCriteriaBuilder + ) { + $this->sourceItemRepository = $sourceItemRepository; + $this->searchCriteriaBuilderFactory = $searchCriteriaBuilder; + } + + /** + * Check if items are ordered form the Pickup location and verify that each item has enough quantity. + * + * @param OrderInterface $order + * @return bool + */ + public function execute(OrderInterface $order): bool + { + $extensionAttributes = $order->getExtensionAttributes(); + if (!$extensionAttributes) { + return false; + } + + $sourceCode = $extensionAttributes->getPickupLocationCode(); + if (!$sourceCode) { + return false; + } + + foreach ($order->getItems() as $item) { + if (!$this->isItemFulfillable($item->getSku(), $sourceCode, (float)$item->getQtyOrdered())) { + return false; + } + } + + return true; + } + + /** + * Check if Pickup Location source has enough item qty. + * + * @param string $sku + * @param string $sourceCode + * @param float $qtyOrdered + * @return bool + */ + private function isItemFulfillable(string $sku, string $sourceCode, float $qtyOrdered): bool + { + $searchCriteria = $this->searchCriteriaBuilderFactory + ->create() + ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCode) + ->addFilter(SourceItemInterface::SKU, $sku) + ->create(); + + $sourceItems = $this->sourceItemRepository->getList($searchCriteria); + if ($sourceItems->getTotalCount()) { + /** @var SourceItemInterface $sourceItem */ + $sourceItem = current($sourceItems->getItems()); + + return bccomp((string)$sourceItem->getQuantity(), (string)$qtyOrdered) >= 0; + } + + return false; + } +} diff --git a/InventoryInStorePickup/Model/PickupLocation.php b/InventoryInStorePickup/Model/PickupLocation.php new file mode 100644 index 000000000000..6969e74a553c --- /dev/null +++ b/InventoryInStorePickup/Model/PickupLocation.php @@ -0,0 +1,304 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model; + +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationExtensionInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; + +/** + * @inheritdoc + * @codeCoverageIgnore + */ +class PickupLocation implements PickupLocationInterface +{ + /** + * @var PickupLocationExtensionInterface + */ + private $extensionAttributes; + + /** + * @var string + */ + private $sourceCode; + + /** + * @var string|null + */ + private $name; + + /** + * @var string|null + */ + private $fax; + + /** + * @var string|null + */ + private $contactName; + + /** + * @var string|null + */ + private $description; + + /** + * @var float|null + */ + private $latitude; + + /** + * @var float|null + */ + private $longitude; + + /** + * @var string|null + */ + private $countryId; + + /** + * @var int|null + */ + private $regionId; + + /** + * @var int|null + */ + private $region; + + /** + * @var string|null + */ + private $city; + + /** + * @var string|null + */ + private $street; + + /** + * @var string|null + */ + private $postcode; + + /** + * @var string|null + */ + private $phone; + + /** + * @var string[]|null + */ + private $openHours; + + /** + * @var string|null + */ + private $email; + + /** + * @param string $sourceCode + * @param string|null $name + * @param string|null $email + * @param string|null $fax + * @param string|null $contactName + * @param string|null $description + * @param float|null $latitude + * @param float|null $longitude + * @param string|null $countryId + * @param int|null $regionId + * @param int|null $region + * @param string|null $city + * @param string|null $street + * @param string|null $postcode + * @param string|null $phone + * @param string[]|null $openHours + * @param PickupLocationExtensionInterface|null $extensionAttributes + */ + public function __construct( + string $sourceCode, + ?string $name = null, + ?string $email = null, + ?string $fax = null, + ?string $contactName = null, + ?string $description = null, + ?float $latitude = null, + ?float $longitude = null, + ?string $countryId = null, + ?int $regionId = null, + ?int $region = null, + ?string $city = null, + ?string $street = null, + ?string $postcode = null, + ?string $phone = null, + ?array $openHours = null, + ?PickupLocationExtensionInterface $extensionAttributes = null + ) { + $this->sourceCode = $sourceCode; + $this->name = $name; + $this->email = $email; + $this->fax = $fax; + $this->contactName = $contactName; + $this->description = $description; + $this->latitude = $latitude; + $this->longitude = $longitude; + $this->countryId = $countryId; + $this->regionId = $regionId; + $this->region = $region; + $this->city = $city; + $this->street = $street; + $this->postcode = $postcode; + $this->phone = $phone; + $this->openHours = $openHours; + $this->extensionAttributes = $extensionAttributes; + } + + /** + * @inheritdoc + */ + public function getSourceCode(): string + { + return $this->sourceCode; + } + + /** + * @inheritdoc + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @inheritdoc + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @inheritdoc + */ + public function getFax(): ?string + { + return $this->fax; + } + + /** + * @inheritdoc + */ + public function getContactName(): ?string + { + return $this->contactName; + } + + /** + * @inheritdoc + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @inheritdoc + */ + public function getLatitude(): ?float + { + return $this->latitude; + } + + /** + * @inheritdoc + */ + public function getLongitude(): ?float + { + return $this->longitude; + } + + /** + * @inheritdoc + */ + public function getCountryId(): ?string + { + return $this->countryId; + } + + /** + * @inheritdoc + */ + public function getRegionId(): ?int + { + return $this->regionId; + } + + /** + * @inheritdoc + */ + public function getRegion(): ?string + { + return $this->region; + } + + /** + * @inheritdoc + */ + public function getCity(): ?string + { + return $this->city; + } + + /** + * @inheritdoc + */ + public function getStreet(): ?string + { + return $this->street; + } + + /** + * @inheritdoc + */ + public function getPostcode(): ?string + { + return $this->postcode; + } + + /** + * @inheritdoc + */ + public function getPhone(): ?string + { + return $this->phone; + } + + /** + * @inheritdoc + */ + public function getOpenHours(): ?array + { + return $this->openHours; + } + + /** + * @inheritdoc + */ + public function setExtensionAttributes(?PickupLocationExtensionInterface $extensionAttributes): void + { + $this->extensionAttributes = $extensionAttributes; + } + + /** + * @inheritdoc + */ + public function getExtensionAttributes(): ?PickupLocationExtensionInterface + { + return $this->extensionAttributes; + } +} diff --git a/InventoryInStorePickup/Model/PickupLocation/Mapper/CreateFromSource.php b/InventoryInStorePickup/Model/PickupLocation/Mapper/CreateFromSource.php new file mode 100644 index 000000000000..58a6ef55716c --- /dev/null +++ b/InventoryInStorePickup/Model/PickupLocation/Mapper/CreateFromSource.php @@ -0,0 +1,183 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\PickupLocation\Mapper; + +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Framework\Api\SimpleDataObjectConverter; +use Magento\InventoryApi\Api\Data\SourceInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterfaceFactory; +use Magento\InventoryInStorePickupApi\Model\Mapper\CreateFromSourceInterface; + +/** + * @inheritdoc + */ +class CreateFromSource implements CreateFromSourceInterface +{ + /** + * @var PickupLocationInterfaceFactory + */ + private $pickupLocationFactory; + + /** + * @var ExtensionAttributesFactory + */ + private $extensionAttributesFactory; + + /** + * CreateFromSource constructor. + * + * @param PickupLocationInterfaceFactory $pickupLocationFactory + * @param ExtensionAttributesFactory $extensionAttributesFactory + */ + public function __construct( + PickupLocationInterfaceFactory $pickupLocationFactory, + ExtensionAttributesFactory $extensionAttributesFactory + ) { + $this->pickupLocationFactory = $pickupLocationFactory; + $this->extensionAttributesFactory = $extensionAttributesFactory; + } + + /** + * @inheritdoc + * @throws \InvalidArgumentException + */ + public function execute(SourceInterface $source, array $map): PickupLocationInterface + { + $mappedData = $this->extractDataFromSource($source, $map); + $data = $this->preparePickupLocationFields($mappedData); + + return $this->pickupLocationFactory->create($data); + } + + /** + * @param array $mappedData + * + * @return array + */ + private function preparePickupLocationFields(array $mappedData): array + { + $pickupLocationExtension = $this->extensionAttributesFactory->create(PickupLocationInterface::class); + $pickupLocationMethods = get_class_methods(PickupLocationInterface::class); + $data = [ + 'extensionAttributes' => $pickupLocationExtension + ]; + + foreach ($mappedData as $pickupLocationField => $value) { + if ($this->isExtensionAttributeField($pickupLocationField)) { + $methodName = $this->getSetterMethodName($this->getExtensionAttributeFieldName($pickupLocationField)); + + if (!method_exists($pickupLocationExtension, $methodName)) { + $this->throwException(PickupLocationInterface::class, $pickupLocationField); + } + $pickupLocationExtension->{$methodName}($value); + } else { + $methodName = $this->getGetterMethodName($pickupLocationField); + if (!in_array($methodName, $pickupLocationMethods)) { + $this->throwException(PickupLocationInterface::class, $pickupLocationField); + } + $data[SimpleDataObjectConverter::snakeCaseToCamelCase($pickupLocationField)] = $value; + } + } + + return $data; + } + + /** + * Extract values from Source according to the provided map. + * + * @param \Magento\InventoryApi\Api\Data\SourceInterface $source + * @param string[] $map + * + * @return array + */ + private function extractDataFromSource(SourceInterface $source, array $map): array + { + $mappedData = []; + foreach ($map as $sourceField => $pickupLocationField) { + if ($this->isExtensionAttributeField($sourceField)) { + $methodName = $this->getGetterMethodName($this->getExtensionAttributeFieldName($sourceField)); + $entity = $source->getExtensionAttributes(); + } else { + $methodName = $this->getGetterMethodName($sourceField); + $entity = $source; + } + + if (!method_exists($entity, $methodName)) { + $this->throwException(SourceInterface::class, $sourceField); + } + + $mappedData[$pickupLocationField] = $entity->{$methodName}(); + } + + return $mappedData; + } + + /** + * Wrapper for throwing Invalid Argument Exception. + * + * @param string $className + * @param string $fieldName + * + * @return void + */ + private function throwException(string $className, string $fieldName): void + { + $message = "Wrong mapping provided for %s. Field '%s' is not found."; + + throw new \InvalidArgumentException(sprintf($message, $className, $fieldName)); + } + + /** + * @param $fieldName + * + * @return string + */ + private function getExtensionAttributeFieldName(string $fieldName): string + { + $field = explode('.', $fieldName); + + return end($field); + } + + /** + * Check if field should be get from extension attributes. + * + * @param $fieldName + * + * @return bool + */ + private function isExtensionAttributeField(string $fieldName): bool + { + return strpos($fieldName, 'extension_attributes.') === 0; + } + + /** + * Get getter name based on field name. + * + * @param string $fieldName + * + * @return string + */ + private function getGetterMethodName(string $fieldName): string + { + return 'get' . SimpleDataObjectConverter::snakeCaseToUpperCamelCase($fieldName); + } + + /** + * Get setter name for Extension Attribute based on field name. + * + * @param string $fieldName + * + * @return string + */ + private function getSetterMethodName(string $fieldName): string + { + return 'set' . SimpleDataObjectConverter::snakeCaseToUpperCamelCase($fieldName); + } +} diff --git a/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationByOrderId.php b/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationCodeByOrderId.php similarity index 89% rename from InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationByOrderId.php rename to InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationCodeByOrderId.php index 307b0bc7fe7b..cfd193a6901d 100644 --- a/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationByOrderId.php +++ b/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/GetPickupLocationCodeByOrderId.php @@ -12,19 +12,19 @@ /** * Get Pickup Location identifier by order identifier. */ -class GetPickupLocationByOrderId +class GetPickupLocationCodeByOrderId { private const ORDER_ID = 'order_id'; private const PICKUP_LOCATION_CODE = 'pickup_location_code'; /** - * @var \Magento\Framework\App\ResourceConnection + * @var ResourceConnection */ private $connection; /** - * @param \Magento\Framework\App\ResourceConnection $connection + * @param ResourceConnection $connection */ public function __construct( ResourceConnection $connection diff --git a/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/SaveOrderPickupLocation.php b/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/SaveOrderPickupLocation.php index 15b07555f4e1..c881dd964df2 100644 --- a/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/SaveOrderPickupLocation.php +++ b/InventoryInStorePickup/Model/ResourceModel/OrderPickupLocation/SaveOrderPickupLocation.php @@ -18,14 +18,12 @@ class SaveOrderPickupLocation private const PICKUP_LOCATION_CODE = 'pickup_location_code'; /** - * @var \Magento\Framework\App\ResourceConnection + * @var ResourceConnection */ private $connection; /** - * GetPickupLocationByOrderId constructor. - * - * @param \Magento\Framework\App\ResourceConnection $connection + * @param ResourceConnection $connection */ public function __construct( ResourceConnection $connection diff --git a/InventoryInStorePickup/Model/ResourceModel/Source/GetDistanceOrderedSourceCodes.php b/InventoryInStorePickup/Model/ResourceModel/Source/GetDistanceOrderedSourceCodes.php new file mode 100644 index 000000000000..ff74e0f4473f --- /dev/null +++ b/InventoryInStorePickup/Model/ResourceModel/Source/GetDistanceOrderedSourceCodes.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Model\ResourceModel\Source; + +use Magento\Framework\App\ResourceConnection; +use Magento\InventoryApi\Api\Data\SourceInterface; +use Magento\InventoryDistanceBasedSourceSelectionApi\Api\Data\LatLngInterface; + +/** + * Get Source Codes, ordered by distance to request coordinates using Haversine formula (Great Circle Distance) database query. + */ +class GetDistanceOrderedSourceCodes +{ + private const EARTH_RADIUS_KM = 6372.797; + + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct(ResourceConnection $resourceConnection) + { + $this->resourceConnection = $resourceConnection; + } + + /** + * @param LatLngInterface $latLng + * @param int $radius + * + * @return string[] + */ + public function execute(LatLngInterface $latLng, int $radius): array + { + $connection = $this->resourceConnection->getConnection(); + $sourceTable = $this->resourceConnection->getTableName('inventory_source'); + $query = $connection->select() + ->from($sourceTable) + ->where(SourceInterface::ENABLED) + ->columns(['source_code', $this->createDistanceColumn($latLng) . ' AS distance']) + ->having('distance <= ?', $radius) + ->order('distance ASC'); + + return $connection->fetchCol($query); + } + + /** + * Construct DB query to calculate Great Circle Distance + * + * @param LatLngInterface $latLng + * + * @return string + */ + private function createDistanceColumn(LatLngInterface $latLng): string + { + return '(' . self::EARTH_RADIUS_KM . ' * ACOS(' + . 'COS(RADIANS(' . $latLng->getLat() . ')) * ' + . 'COS(RADIANS(latitude)) * ' + . 'COS(RADIANS(longitude) - RADIANS(' . $latLng->getLng() . ')) + ' + . 'SIN(RADIANS(' . $latLng->getLat() . ')) * ' + . 'SIN(RADIANS(latitude))' + . '))'; + } +} diff --git a/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetListPlugin.php b/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetListPlugin.php index cf99f2d34320..637bc1765803 100644 --- a/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetListPlugin.php +++ b/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetListPlugin.php @@ -16,14 +16,12 @@ class LoadInStorePickupOnGetListPlugin { /** - * @var \Magento\Framework\Api\ExtensionAttributesFactory + * @var ExtensionAttributesFactory */ private $extensionAttributesFactory; /** - * LoadInStorePickupOnGetListPlugin constructor. - * - * @param \Magento\Framework\Api\ExtensionAttributesFactory $extensionAttributesFactory + * @param ExtensionAttributesFactory $extensionAttributesFactory */ public function __construct(ExtensionAttributesFactory $extensionAttributesFactory) { diff --git a/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetPlugin.php b/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetPlugin.php index 60ef170a55c2..e4e947154402 100644 --- a/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetPlugin.php +++ b/InventoryInStorePickup/Plugin/InventoryApi/SourceRepository/LoadInStorePickupOnGetPlugin.php @@ -20,8 +20,6 @@ class LoadInStorePickupOnGetPlugin private $extensionAttributesFactory; /** - * LoadInStorePickupOnGetPlugin constructor. - * * @param \Magento\Framework\Api\ExtensionAttributesFactory $extensionAttributesFactory */ public function __construct(ExtensionAttributesFactory $extensionAttributesFactory) diff --git a/InventoryInStorePickup/Plugin/Sales/Order/GetPickupLocationForOrderPlugin.php b/InventoryInStorePickup/Plugin/Sales/Order/GetPickupLocationForOrderPlugin.php index fa34698bcebc..9db347a5848e 100644 --- a/InventoryInStorePickup/Plugin/Sales/Order/GetPickupLocationForOrderPlugin.php +++ b/InventoryInStorePickup/Plugin/Sales/Order/GetPickupLocationForOrderPlugin.php @@ -7,7 +7,7 @@ namespace Magento\InventoryInStorePickup\Plugin\Sales\Order; -use Magento\InventoryInStorePickup\Model\ResourceModel\OrderPickupLocation\GetPickupLocationByOrderId; +use Magento\InventoryInStorePickup\Model\ResourceModel\OrderPickupLocation\GetPickupLocationCodeByOrderId; use Magento\Sales\Api\Data\OrderExtensionFactory; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; @@ -23,17 +23,17 @@ class GetPickupLocationForOrderPlugin private $orderExtensionFactory; /** - * @var GetPickupLocationByOrderId + * @var GetPickupLocationCodeByOrderId */ private $getPickupLocationByOrderId; /** * @param OrderExtensionFactory $orderExtensionFactory - * @param GetPickupLocationByOrderId $getPickupLocationByOrderId + * @param GetPickupLocationCodeByOrderId $getPickupLocationByOrderId */ public function __construct( OrderExtensionFactory $orderExtensionFactory, - GetPickupLocationByOrderId $getPickupLocationByOrderId + GetPickupLocationCodeByOrderId $getPickupLocationByOrderId ) { $this->orderExtensionFactory = $orderExtensionFactory; $this->getPickupLocationByOrderId = $getPickupLocationByOrderId; @@ -52,7 +52,7 @@ public function afterGet(OrderRepositoryInterface $orderRepository, OrderInterfa { $extension = $order->getExtensionAttributes(); - if (empty($extension)) { + if (null === $extension) { $extension = $this->orderExtensionFactory->create(); } diff --git a/InventoryInStorePickup/Plugin/Sales/Order/SavePickupLocationForOrderPlugin.php b/InventoryInStorePickup/Plugin/Sales/Order/SavePickupLocationForOrderPlugin.php index a0c163d4631c..cb44d9bdc09e 100644 --- a/InventoryInStorePickup/Plugin/Sales/Order/SavePickupLocationForOrderPlugin.php +++ b/InventoryInStorePickup/Plugin/Sales/Order/SavePickupLocationForOrderPlugin.php @@ -32,11 +32,11 @@ public function __construct(SaveOrderPickupLocation $saveOrderPickupLocation) /** * Save Order to Pickup Location relation when saving the order. * - * @param \Magento\Sales\Api\OrderRepositoryInterface $orderRepository - * @param \Magento\Sales\Api\Data\OrderInterface $result - * @param \Magento\Sales\Api\Data\OrderInterface $entity + * @param OrderRepositoryInterface $orderRepository + * @param OrderInterface $result + * @param OrderInterface $entity * - * @return \Magento\Sales\Api\Data\OrderInterface + * @return OrderInterface * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function afterSave( @@ -46,7 +46,7 @@ public function afterSave( ) { $extension = $result->getExtensionAttributes(); - if (!empty($extension) && $extension->getPickupLocationCode()) { + if (null !== $extension && $extension->getPickupLocationCode()) { $this->saveOrderPickupLocation->execute((int)$result->getEntityId(), $extension->getPickupLocationCode()); } diff --git a/InventoryInStorePickup/Test/Integration/DistanceProvider/Offline/GetNearbySourcesByPostcodeTest.php b/InventoryInStorePickup/Test/Integration/DistanceProvider/Offline/GetNearbySourcesByPostcodeTest.php deleted file mode 100644 index c42c7c8dc9c8..000000000000 --- a/InventoryInStorePickup/Test/Integration/DistanceProvider/Offline/GetNearbySourcesByPostcodeTest.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\InventoryInStorePickup\Test\Integration\DistanceProvider\Offline; - -use Magento\InventoryApi\Api\Data\SourceInterface; -use Magento\InventoryInStorePickup\Model\DistanceProvider\Offline\GetNearbySourcesByPostcode; -use Magento\TestFramework\Helper\Bootstrap; -use PHPUnit\Framework\TestCase; - -class GetNearbySourcesByPostcodeTest extends TestCase -{ - /** - * @var GetNearbySourcesByPostcode - */ - private $getNearbySourcesByPostcode; - - protected function setUp() - { - $this->getNearbySourcesByPostcode = Bootstrap::getObjectManager()->get(GetNearbySourcesByPostcode::class); - } - - /** - * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/source_addresses.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php - * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/inventory_geoname.php - * - * @param string $country - * @param string $postcode - * @param int $radius - * @param array $sortedSourceCodes - * - * @dataProvider executeDataProvider - * - * @magentoDbIsolation disabled - * @throws - */ - public function testExecute(string $country, string $postcode, int $radius, array $sortedSourceCodes) - { - /** @var SourceInterface[] $sources */ - $sources = $this->getNearbySourcesByPostcode->execute($country, $postcode, $radius); - - $this->assertCount(count($sortedSourceCodes), $sources); - foreach ($sortedSourceCodes as $key => $code) { - $this->assertEquals($code, $sources[$key]->getSourceCode()); - } - } - - /** - * @return array - */ - public function executeDataProvider(): array - { - return [ - ['DE', '81671', 500, ['eu-3']], - ['FR', '56290', 1000, ['eu-1', 'eu-2']], - ['FR', '84490', 1000, ['eu-2', 'eu-1', 'eu-3']], - ['IT', '12022', 350, ['eu-2']], - ['IT', '39030', 350, ['eu-3']], - ['DE', '26419', 750, ['eu-1', 'eu-3']], - ]; - } -} diff --git a/InventoryInStorePickup/Test/Integration/Extension/InventorySourceExtensionTest.php b/InventoryInStorePickup/Test/Integration/Extension/InventorySourceExtensionTest.php index 17d825058caf..af2ef3f42817 100644 --- a/InventoryInStorePickup/Test/Integration/Extension/InventorySourceExtensionTest.php +++ b/InventoryInStorePickup/Test/Integration/Extension/InventorySourceExtensionTest.php @@ -8,6 +8,7 @@ namespace Magento\InventoryInStorePickup\Test\Integration\Extension; use Magento\Framework\ObjectManagerInterface; +use Magento\InventoryApi\Api\Data\SourceInterface; use Magento\InventoryApi\Api\SourceRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; @@ -43,7 +44,7 @@ public function testGetListOfSourcesWithPickupLocationExtensionAfterSave() $searchResult = $this->sourceRepository->getList(); - /** @var \Magento\InventoryApi\Api\Data\SourceInterface $item */ + /** @var SourceInterface $item */ foreach ($searchResult->getItems() as $item) { $item->getExtensionAttributes()->setIsPickupLocationActive( $pickupLocationConfig[$item->getSourceCode()] diff --git a/InventoryInStorePickup/Test/Integration/GetNearbyPickupLocationsOfflineTest.php b/InventoryInStorePickup/Test/Integration/GetNearbyPickupLocationsOfflineTest.php new file mode 100644 index 000000000000..255be6044c74 --- /dev/null +++ b/InventoryInStorePickup/Test/Integration/GetNearbyPickupLocationsOfflineTest.php @@ -0,0 +1,155 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Test\Integration; + +use Magento\InventoryInStorePickup\Model\AddressFactory; +use Magento\InventoryInStorePickup\Model\GetNearbyPickupLocations; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class GetNearbyPickupLocationsOfflineTest extends TestCase +{ + /** + * @var GetNearbyPickupLocations + */ + private $getNearbyPickupLocations; + + /** + * @var AddressFactory + */ + private $addressFactory; + + protected function setUp() + { + $this->getNearbyPickupLocations = Bootstrap::getObjectManager()->get(GetNearbyPickupLocations::class); + $this->addressFactory = Bootstrap::getObjectManager()->get(AddressFactory::class); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/source_addresses.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/source_pickup_location_attributes.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/inventory_geoname.php + * @magentoConfigFixture default/cataloginventory/source_selection_distance_based/provider offline + * + * @param array $addressData + * @param int $radius + * @param int $stockId + * @param string[] $sortedSourceCodes + * + * @dataProvider executeDataProvider + * @magentoAppArea frontend + * + * @magentoDbIsolation disabled + */ + public function testExecute( + array $addressData, + int $radius, + int $stockId, + array $sortedSourceCodes + ) { + $address = $this->addressFactory->create($addressData); + + /** @var PickupLocationInterface[] $sources */ + $pickupLocations = $this->getNearbyPickupLocations->execute($address, $radius, $stockId); + + $this->assertCount(count($sortedSourceCodes), $pickupLocations); + foreach ($sortedSourceCodes as $key => $code) { + $this->assertEquals($code, $pickupLocations[$key]->getSourceCode()); + } + } + + /** + * [ + * Address[ + * Country, + * Postcode, + * Region, + * City + * ] + * Radius (in KM), + * Stock Id, + * Expected Source Codes[] + * ] + * + * @return array + */ + public function executeDataProvider(): array + { + return [ + [ + [ + 'country' => 'DE', + 'postcode' => '81671' + ], + 500, + 10, + ['eu-3'] + ], + [ + [ + 'country' => 'FR', + 'region' => 'Bretagne' + ], + 1000, + 10, + ['eu-1'] + ], + [ + [ + 'country' => 'FR', + 'city' => 'Saint-Saturnin-lès-Apt' + ], + 1000, + 30, + ['eu-1', 'eu-3'] + ], + [ + [ + 'country' => 'IT', + 'postcode' => '12022' + ], + 350, + 10, + [] + ], + [ + [ + 'country' => 'IT', + 'postcode' => '39030', + 'region' => 'Trentino-Alto Adige', + 'city' => 'Rasun Di Sotto' + ], + 350, + 10, + ['eu-3'] + ], + [ + [ + 'country' => 'DE', + 'postcode' => '86559', + ], + 750, + 30, + ['eu-3', 'eu-1'] + ], + [ + [ + 'country' => 'US', + 'region' => 'Kansas' + ], + 1000, + 20, + ['us-1'] + ] + ]; + } +} diff --git a/InventoryInStorePickup/Test/Integration/GetPickupLocationsAssignedToStockOrderedByPriorityTest.php b/InventoryInStorePickup/Test/Integration/GetPickupLocationsAssignedToStockOrderedByPriorityTest.php new file mode 100644 index 000000000000..0e31a2a76006 --- /dev/null +++ b/InventoryInStorePickup/Test/Integration/GetPickupLocationsAssignedToStockOrderedByPriorityTest.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Test\Integration; + +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\InventoryInStorePickup\Model\GetPickupLocationsAssignedToStockOrderedByPriority; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class GetPickupLocationsAssignedToStockOrderedByPriorityTest extends TestCase +{ + /** + * @var GetPickupLocationsAssignedToStockOrderedByPriority + */ + private $getPickupLocations; + + protected function setUp() + { + $this->getPickupLocations = Bootstrap::getObjectManager()->get( + GetPickupLocationsAssignedToStockOrderedByPriority::class + ); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/source_addresses.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryInStorePickup/Test/_files/source_pickup_location_attributes.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * + * @param int $stockId + * @param string[] $sortedSourceCodes + * + * @throws \Magento\Framework\Exception\LocalizedException + * @dataProvider executeDataProvider + * @magentoAppArea frontend + * + * @magentoDbIsolation disabled + */ + public function testExecute(int $stockId, array $sortedSourceCodes) + { + /** @var PickupLocationInterface[] $sources */ + $pickupLocations = $this->getPickupLocations->execute($stockId); + + $this->assertCount(count($sortedSourceCodes), $pickupLocations); + foreach ($sortedSourceCodes as $key => $code) { + $this->assertEquals($code, $pickupLocations[$key]->getSourceCode()); + } + } + + /** + * [ + * Stock Id, + * Expected Source Codes[] + * ] + * @return array + */ + public function executeDataProvider(): array + { + return [ + [10, ['eu-1', 'eu-3']], + [20, ['us-1']], + [30, ['us-1', 'eu-3', 'eu-1']] + ]; + } +} diff --git a/InventoryInStorePickup/Test/Integration/PickupLocation/MapperTest.php b/InventoryInStorePickup/Test/Integration/PickupLocation/MapperTest.php new file mode 100644 index 000000000000..23ff6a97e05b --- /dev/null +++ b/InventoryInStorePickup/Test/Integration/PickupLocation/MapperTest.php @@ -0,0 +1,203 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickup\Test\Integration\PickupLocation; + +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\InventoryApi\Api\Data\SourceExtensionInterface; +use Magento\InventoryApi\Api\SourceRepositoryInterface; +use Magento\InventoryInStorePickupApi\Model\Mapper\CreateFromSourceInterface; +use Magento\InventoryInStorePickupApi\Model\Mapper; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationExtensionInterface; +use Magento\TestFramework\Helper\Bootstrap; + +class MapperTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Framework\ObjectManagerInterface + */ + private $objectManager; + + /** + * @var \Magento\InventoryApi\Api\SourceRepositoryInterface + */ + private $sourceRepository; + + /** + * @var string + */ + private $sourceCode; + + protected function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->sourceRepository = $this->objectManager->create(SourceRepositoryInterface::class); + $this->sourceCode = 'source-code-1'; + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source.php + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Wrong mapping provided for Magento\InventoryApi\Api\Data\SourceInterface. Field 'source_fail_field' is not found. + */ + public function testWrongMappingForSource() + { + $source = $this->sourceRepository->get($this->sourceCode); + $map = $this->getMap(); + $map['source_fail_field'] = 'fail_field'; + /** @var Mapper $mapper */ + $mapper = $this->objectManager->create(Mapper::class, ['map' => $map]); + $mapper->map($source); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source.php + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Wrong mapping provided for Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface. Field 'extension_attributes.fail_field' is not found. + */ + public function testWrongMappingForPickupLocationExtensionAttributes() + { + $source = $this->sourceRepository->get($this->sourceCode); + $map = $this->getMap(); + $map['name'] = 'extension_attributes.fail_field'; + /** @var Mapper $mapper */ + $mapper = $this->objectManager->create(Mapper::class, ['map' => $map]); + $mapper->map($source); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source.php + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Wrong mapping provided for Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface. Field 'fail_field' is not found. + */ + public function testWrongMappingForPickupLocation() + { + $source = $this->sourceRepository->get($this->sourceCode); + $map = $this->getMap(); + $map['name'] = 'fail_field'; + /** @var Mapper $mapper */ + $mapper = $this->objectManager->create(Mapper::class, ['map' => $map]); + $mapper->map($source); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source.php + */ + public function testMapPickupLocation() + { + $source = $this->sourceRepository->get($this->sourceCode); + /** @var Mapper $mapper */ + $mapper = $this->objectManager->create(Mapper::class, ['map' => $this->getMap()]); + $pickupLocation = $mapper->map($source); + + $this->assertEquals($source->getSourceCode(), $pickupLocation->getSourceCode()); + $this->assertEquals($source->getEmail(), $pickupLocation->getEmail()); + $this->assertEquals($source->getContactName(), $pickupLocation->getContactName()); + $this->assertEquals($source->getDescription(), $pickupLocation->getDescription()); + $this->assertEquals($source->getLatitude(), $pickupLocation->getLatitude()); + $this->assertEquals($source->getLongitude(), $pickupLocation->getLongitude()); + $this->assertEquals($source->getCountryId(), $pickupLocation->getCountryId()); + $this->assertEquals($source->getRegionId(), $pickupLocation->getRegionId()); + $this->assertEquals($source->getRegion(), $pickupLocation->getRegion()); + $this->assertEquals($source->getCity(), $pickupLocation->getCity()); + $this->assertEquals($source->getStreet(), $pickupLocation->getStreet()); + $this->assertEquals($source->getPostcode(), $pickupLocation->getPostcode()); + $this->assertEquals($source->getPhone(), $pickupLocation->getPhone()); + $this->assertEquals($source->getFax(), $pickupLocation->getFax()); + $this->assertInstanceOf(PickupLocationExtensionInterface::class, $pickupLocation->getExtensionAttributes()); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source.php + */ + public function testMapPickupLocationWithExtensionAttributes() + { + $source = $this->sourceRepository->get($this->sourceCode); + + $sourceExtensionAttributes = $this->getMockBuilder(SourceExtensionInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getOpenHours', 'getSomeAttribute']) + ->getMockForAbstractClass(); + $sourceExtensionAttributes->expects($this->once()) + ->method('getOpenHours') + ->willReturn(['open', 'hours']); + $sourceExtensionAttributes->expects($this->once()) + ->method('getSomeAttribute') + ->willReturn('some_value'); + $source->setExtensionAttributes($sourceExtensionAttributes); + + $pickupLocationExtension = $this->getMockBuilder(PickupLocationExtensionInterface::class) + ->disableOriginalConstructor() + ->setMethods(['setPickupLocationAttribute']) + ->getMock(); + $pickupLocationExtension->expects($this->once()) + ->method('setPickupLocationAttribute') + ->with('some_value'); + + $extensionAttributesFactory = $this->getMockBuilder(ExtensionAttributesFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $extensionAttributesFactory->expects($this->once()) + ->method('create') + ->willReturn($pickupLocationExtension); + + $createFromSource = $this->objectManager->create( + CreateFromSourceInterface::class, + ['extensionAttributesFactory' => $extensionAttributesFactory] + ); + + $map = $this->getMap(); + $map['extension_attributes.open_hours'] = 'open_hours'; + $map['extension_attributes.some_attribute'] = 'extension_attributes.pickup_location_attribute'; + + /** @var Mapper $mapper */ + $mapper = $this->objectManager->create( + Mapper::class, + ['map' => $map, 'createFromSource' => $createFromSource] + ); + $pickupLocation = $mapper->map($source); + + $this->assertEquals($source->getSourceCode(), $pickupLocation->getSourceCode()); + $this->assertEquals($source->getEmail(), $pickupLocation->getEmail()); + $this->assertEquals($source->getContactName(), $pickupLocation->getContactName()); + $this->assertEquals($source->getDescription(), $pickupLocation->getDescription()); + $this->assertEquals($source->getLatitude(), $pickupLocation->getLatitude()); + $this->assertEquals($source->getLongitude(), $pickupLocation->getLongitude()); + $this->assertEquals($source->getCountryId(), $pickupLocation->getCountryId()); + $this->assertEquals($source->getRegionId(), $pickupLocation->getRegionId()); + $this->assertEquals($source->getRegion(), $pickupLocation->getRegion()); + $this->assertEquals($source->getCity(), $pickupLocation->getCity()); + $this->assertEquals($source->getStreet(), $pickupLocation->getStreet()); + $this->assertEquals($source->getPostcode(), $pickupLocation->getPostcode()); + $this->assertEquals($source->getPhone(), $pickupLocation->getPhone()); + $this->assertEquals($source->getFax(), $pickupLocation->getFax()); + $this->assertEquals(['open', 'hours'], $pickupLocation->getOpenHours()); + } + + /** + * @return array + */ + private function getMap(): array + { + return [ + 'source_code' => 'source_code', + 'email' => 'email', + 'fax' => 'fax', + 'contact_name' => 'contact_name', + 'description' => 'description', + 'latitude' => 'latitude', + 'longitude' => 'longitude', + 'country_id' => 'country_id', + 'region_id' => 'region_id', + 'region' => 'region', + 'city' => 'city', + 'street' => 'street', + 'postcode' => 'postcode', + 'phone' => 'phone' + ]; + } +} diff --git a/InventoryInStorePickup/Test/_files/source_pickup_location_attributes.php b/InventoryInStorePickup/Test/_files/source_pickup_location_attributes.php new file mode 100644 index 000000000000..0c9195409bf3 --- /dev/null +++ b/InventoryInStorePickup/Test/_files/source_pickup_location_attributes.php @@ -0,0 +1,38 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\InventoryApi\Api\SourceRepositoryInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var SourceRepositoryInterface $sourceRepository */ +$sourceRepository = Bootstrap::getObjectManager()->get(SourceRepositoryInterface::class); + +$pickupLocationAttributesMap = [ + 'eu-1' => [ + PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE => true + ], + 'eu-2' => [ + PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE => false + ], + 'eu-3' => [ + PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE => true + ], + 'eu-disabled' => [ + PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE => false + ], + 'us-1' => [ + PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE => true + ] +]; + +foreach ($pickupLocationAttributesMap as $sourceCode => $value) { + $source = $sourceRepository->get($sourceCode); + $extension = $source->getExtensionAttributes(); + $extension->setIsPickupLocationActive($value[PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE]); + $sourceRepository->save($source); +} diff --git a/InventoryInStorePickup/composer.json b/InventoryInStorePickup/composer.json index caa00eafee06..3194163c4cb8 100644 --- a/InventoryInStorePickup/composer.json +++ b/InventoryInStorePickup/composer.json @@ -5,8 +5,11 @@ "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-inventory-in-store-pickup-api": "*", + "magento/module-inventory-distance-based-source-selection-api": "*", "magento/module-inventory-api": "*", - "magento/module-sales": "*" + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-inventory-source-selection-api": "*" }, "type": "magento2-module", "license": [ diff --git a/InventoryInStorePickup/etc/config.xml b/InventoryInStorePickup/etc/config.xml new file mode 100644 index 000000000000..33c4380f25c2 --- /dev/null +++ b/InventoryInStorePickup/etc/config.xml @@ -0,0 +1,26 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd"> + <default> + <storepickup_email> + <order_ready_for_pickup> + <enabled>1</enabled> + <template>inventory_instorepickup_order_ready_for_pickup_template</template> + <guest_template>inventory_instorepickup_order_ready_for_pickup_template</guest_template> + <identity>storepickup</identity> + <copy_method>bcc</copy_method> + </order_ready_for_pickup> + </storepickup_email> + <trans_email> + <ident_storepickup> + <email>sales@example.com</email> + <name>Store pickup</name> + </ident_storepickup> + </trans_email> + </default> +</config> diff --git a/InventoryInStorePickup/etc/di.xml b/InventoryInStorePickup/etc/di.xml index 04f52ef77cdf..2c49b2118556 100644 --- a/InventoryInStorePickup/etc/di.xml +++ b/InventoryInStorePickup/etc/di.xml @@ -17,12 +17,19 @@ </type> <preference for="Magento\InventoryInStorePickupApi\Api\GetNearbySourcesByPostcodeInterface" type="Magento\InventoryInStorePickup\Model\GetNearbySourcesByPostcode"/> + <preference for="Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface" type="Magento\InventoryInStorePickup\Model\PickupLocation" /> + <preference for="Magento\InventoryInStorePickupApi\Api\GetNearbyPickupLocationsInterface" type="Magento\InventoryInStorePickup\Model\GetNearbyPickupLocations"/> + <preference for="Magento\InventoryInStorePickupApi\Api\Data\AddressInterface" type="Magento\InventoryInStorePickup\Model\Address" /> + <preference for="Magento\InventoryInStorePickupApi\Api\GetPickupLocationsAssignedToStockOrderedByPriorityInterface" type="Magento\InventoryInStorePickup\Model\GetPickupLocationsAssignedToStockOrderedByPriority" /> - <type name="Magento\InventoryInStorePickupApi\Model\GetNearbySourcesByPostcode"> + <preference for="Magento\InventoryInStorePickupApi\Api\NotifyOrderIsReadyForPickupInterface" + type="Magento\InventoryInStorePickup\Model\NotifyOrderIsReadyForPickup"/> + <preference for="Magento\InventoryInStorePickupApi\Api\IsOrderReadyForPickupInterface" + type="Magento\InventoryInStorePickup\Model\IsOrderReadyForPickup"/> + <type name="Magento\InventoryInStorePickup\Model\Order\Email\ReadyForPickupSender"> <arguments> - <argument name="providers" xsi:type="array"> - <item name="offline" xsi:type="object">Magento\InventoryInStorePickup\Model\DistanceProvider\Offline\GetNearbySourcesByPostcode</item> - </argument> + <argument name="identityContainer" xsi:type="object">\Magento\InventoryInStorePickup\Model\Order\Email\Container\ReadyForPickupIdentity</argument> </arguments> </type> + <preference for="Magento\InventoryInStorePickupApi\Model\Mapper\CreateFromSourceInterface" type="Magento\InventoryInStorePickup\Model\PickupLocation\Mapper\CreateFromSource" /> </config> diff --git a/InventoryInStorePickup/etc/email_templates.xml b/InventoryInStorePickup/etc/email_templates.xml new file mode 100644 index 000000000000..58fb410e0239 --- /dev/null +++ b/InventoryInStorePickup/etc/email_templates.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd"> + <template id="inventory_instorepickup_order_ready_for_pickup_template" label="Order is Ready for Pickup" + file="order_ready_for_pickup.html" type="html" module="Magento_InventoryInStorePickup" area="frontend" + /> +</config> diff --git a/InventoryInStorePickup/etc/module.xml b/InventoryInStorePickup/etc/module.xml index b9efa068ad00..3d2f7e580ff1 100644 --- a/InventoryInStorePickup/etc/module.xml +++ b/InventoryInStorePickup/etc/module.xml @@ -12,6 +12,9 @@ <module name="Magento_InventoryInStorePickupApi"/> <module name="Magento_InventoryDistanceBasedSourceSelection" /> <module name="Magento_Sales" /> + <module name="Magento_Store" /> + <module name="Magento_InventoryDistanceBasedSourceSelectionApi" /> + <module name="Magento_InventorySourceSelectionApi" /> </sequence> </module> </config> diff --git a/InventoryInStorePickup/i18n/en_US.csv b/InventoryInStorePickup/i18n/en_US.csv new file mode 100644 index 000000000000..984e2ad6a211 --- /dev/null +++ b/InventoryInStorePickup/i18n/en_US.csv @@ -0,0 +1 @@ +"The order is not ready for pickup","The order is not ready for pickup" diff --git a/InventoryInStorePickup/view/frontend/email/order_ready_for_pickup.html b/InventoryInStorePickup/view/frontend/email/order_ready_for_pickup.html new file mode 100644 index 000000000000..908ec0a30a70 --- /dev/null +++ b/InventoryInStorePickup/view/frontend/email/order_ready_for_pickup.html @@ -0,0 +1,53 @@ +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<!--@subject {{trans "Your %store_name order is ready for pickup" store_name=$store.getFrontendName()}} @--> +<!--@vars { +"var order.getEmailCustomerNote()":"Email Order Note", +"var order.increment_id":"Order Id", +"var formattedShippingAddress|raw":"Shipping Address", +"var order.getShippingDescription()":"Shipping Description", +} @--> + +{{template config_path="design/email/header_template"}} + +<table> + <tr class="email-intro"> + <td> + <p class="greeting">{{trans "%customer_name," customer_name=$order.getCustomerName()}}</p> + <p> + {{trans "Your order placed from %store_name is ready for pickup." store_name=$store.getFrontendName()}} + </p> + </td> + </tr> + <tr class="email-summary"> + <td> + <h1>{{trans 'Your Order <span class="no-link">#%increment_id</span> is complete' + increment_id=$order.increment_id |raw}}</h1> + <p>{{trans "Please fell free to pickup your order now."}}</p> + </td> + </tr> + <tr class="email-information"> + <td> + <table class="order-details"> + <tr> + <td class="address-details"> + <h3>{{trans "Shipping Info"}}</h3> + <p>{{var formattedShippingAddress|raw}}</p> + </td> + </tr> + <tr> + <td class="method-info"> + <h3>{{trans "Shipping Method"}}</h3> + <p>{{var order.getShippingDescription()}}</p> + </td> + </tr> + </table> + </td> + </tr> +</table> + +{{template config_path="design/email/footer_template"}} diff --git a/InventoryInStorePickupAdminUi/Block/Adminhtml/Order/View/ReadyForPickup.php b/InventoryInStorePickupAdminUi/Block/Adminhtml/Order/View/ReadyForPickup.php new file mode 100644 index 000000000000..15656b9002e8 --- /dev/null +++ b/InventoryInStorePickupAdminUi/Block/Adminhtml/Order/View/ReadyForPickup.php @@ -0,0 +1,106 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupAdminUi\Block\Adminhtml\Order\View; + +use Magento\Backend\Block\Widget\Context; +use Magento\Backend\Block\Widget\Form\Container; +use Magento\InventoryInStorePickupAdminUi\Controller\Adminhtml\Order\NotifyPickup; +use Magento\InventoryInStorePickupAdminUi\Model\IsDisplayReadyForPickupButton; +use Magento\Sales\Block\Adminhtml\Order\View; + +/** + * TODO: is it possible to replace with UI Component? + * @api + * @see https://github.com/magento-engcom/msi/issues/2161 + * + * Render 'Notify Order is Ready for Pickup' button on order view page + */ +class ReadyForPickup extends Container +{ + /** + * @inheritdoc + */ + protected $_blockGroup = 'Magento_Sales'; + + /** + * @var View + */ + private $viewBlock; + + /** + * @var IsDisplayReadyForPickupButton + */ + private $isDisplayButton; + + /** + * ReadyForPickup constructor. + * + * @param Context $context + * @param View $viewBlock + * @param IsDisplayReadyForPickupButton $isDisplayButton + * @param array $data + */ + public function __construct( + Context $context, + View $viewBlock, + IsDisplayReadyForPickupButton $isDisplayButton, + array $data = [] + ) { + $this->viewBlock = $viewBlock; + $this->isDisplayButton = $isDisplayButton; + + parent::__construct($context, $data); + } + + /** + * Rendering Ready for Pickup button + */ + protected function _construct() + { + $this->_objectId = 'order_id'; + $this->_controller = 'adminhtml_order'; + $this->_mode = 'view'; + + if (!$this->isDisplayButton()) { + return; + } + + $message = __( + 'Are you sure you want to notify the customer that order is ready for pickup and create shipment?' + ); + $this->addButton( + 'ready_for_pickup', + [ + 'label' => __('Notify Order is Ready for Pickup'), + 'class' => 'action-default ready-for-pickup', + 'onclick' => sprintf( + "confirmSetLocation('%s', '%s')", + $message, + $this->viewBlock->getUrl('sales/*/notifyPickup') + ) + ] + ); + } + + /** + * @return bool + */ + private function isEmailsSendingAllowed(): bool + { + return $this->_authorization->isAllowed(NotifyPickup::ADMIN_RESOURCE); + } + + /** + * @return bool + */ + private function isDisplayButton(): bool + { + return $this->isEmailsSendingAllowed() + && $this->isDisplayButton->execute($this->viewBlock->getOrder()); + } +} diff --git a/InventoryInStorePickupAdminUi/Controller/Adminhtml/Order/NotifyPickup.php b/InventoryInStorePickupAdminUi/Controller/Adminhtml/Order/NotifyPickup.php new file mode 100644 index 000000000000..90d41d705389 --- /dev/null +++ b/InventoryInStorePickupAdminUi/Controller/Adminhtml/Order/NotifyPickup.php @@ -0,0 +1,120 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupAdminUi\Controller\Adminhtml\Order; + +use Exception; +use Magento\Backend\App\Action; +use Magento\Backend\App\Action\Context; +use Magento\Framework\Controller\ResultInterface; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\InventoryInStorePickupApi\Api\NotifyOrderIsReadyForPickupInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Psr\Log\LoggerInterface; + +/** + * Notify Customer of order pickup availability. + */ +class NotifyPickup extends Action +{ + /** + * Authorization level of a basic admin session + * + * @see _isAllowed() + */ + const ADMIN_RESOURCE = 'Magento_Sales::emails'; + + /** + * @var NotifyOrderIsReadyForPickupInterface + */ + private $notifyOrderIsReadyForPickup; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param Context $context + * @param NotifyOrderIsReadyForPickupInterface $notifyOrderIsReadyForPickup + * @param OrderRepositoryInterface $orderRepository + * @param LoggerInterface $logger + */ + public function __construct( + Context $context, + NotifyOrderIsReadyForPickupInterface $notifyOrderIsReadyForPickup, + OrderRepositoryInterface $orderRepository, + LoggerInterface $logger + ) { + $this->notifyOrderIsReadyForPickup = $notifyOrderIsReadyForPickup; + $this->orderRepository = $orderRepository; + $this->logger = $logger; + + parent::__construct($context); + } + + /** + * Notify customer by email + * + * @return ResultInterface + */ + public function execute(): ResultInterface + { + try { + $order = $this->initOrder(); + } catch (LocalizedException $e) { + return $this->resultRedirectFactory->create()->setPath('sales/*/'); + } + + try { + $this->notifyOrderIsReadyForPickup->execute((int)$order->getEntityId()); + $this->messageManager->addSuccessMessage(__('The customer have been notified and shipment created.')); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (Exception $e) { + $this->messageManager->addErrorMessage(__('We can\'t notify the customer right now.')); + $this->logger->critical($e); + } + + return $this->resultRedirectFactory->create()->setPath( + 'sales/order/view', + [ + 'order_id' => $order->getEntityId(), + ] + ); + } + + /** + * Initialize order model instance + * + * @return OrderInterface + * @throws InputException + * @throws NoSuchEntityException + * @see \Magento\Sales\Controller\Adminhtml\Order::_initOrder + */ + private function initOrder(): OrderInterface + { + $id = $this->getRequest()->getParam('order_id'); + try { + $order = $this->orderRepository->get($id); + } catch (NoSuchEntityException|InputException $e) { + $this->messageManager->addErrorMessage(__('This order no longer exists.')); + $this->_actionFlag->set('', self::FLAG_NO_DISPATCH, true); + throw $e; + } + + return $order; + } +} diff --git a/InventoryInStorePickupAdminUi/Model/IsDisplayReadyForPickupButton.php b/InventoryInStorePickupAdminUi/Model/IsDisplayReadyForPickupButton.php new file mode 100644 index 000000000000..9538f16f3c10 --- /dev/null +++ b/InventoryInStorePickupAdminUi/Model/IsDisplayReadyForPickupButton.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupAdminUi\Model; + +use Magento\Sales\Model\Order; + +/** + * Check if 'Notify Order is Ready for Pickup' button should be rendered + */ +class IsDisplayReadyForPickupButton +{ + /** + * @param Order $order + * + * @return bool + */ + public function execute(Order $order): bool + { + $extensionAttributes = $order->getExtensionAttributes(); + if ($extensionAttributes === null) { + return false; + } + + return $extensionAttributes->getPickupLocationCode() + && $order->canShip(); + } +} diff --git a/InventoryInStorePickupAdminUi/Ui/Component/Listing/Column/IsPickupLocationActive.php b/InventoryInStorePickupAdminUi/Ui/Component/Listing/Column/IsPickupLocationActive.php index 03d504fe47df..4a608b739b57 100644 --- a/InventoryInStorePickupAdminUi/Ui/Component/Listing/Column/IsPickupLocationActive.php +++ b/InventoryInStorePickupAdminUi/Ui/Component/Listing/Column/IsPickupLocationActive.php @@ -21,14 +21,26 @@ class IsPickupLocationActive extends Column */ public function prepareDataSource(array $dataSource):array { - if (isset($dataSource['data']['totalRecords']) - && $dataSource['data']['totalRecords'] > 0 - ) { - foreach ($dataSource['data']['items'] as &$row) { - $row[PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE] = - $row[ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY] - [PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE] ?? ''; - } + if (!isset($dataSource['data']['totalRecords'])) { + return $dataSource; + } + + if ((int)$dataSource['data']['totalRecords'] === 0) { + return $dataSource; + } + + return $this->normalizeData($dataSource); + } + + /** + * @param array $dataSource + * @return array + */ + private function normalizeData(array $dataSource):array + { + foreach ($dataSource['data']['items'] as &$row) { + $row[PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE] = + $row[ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY][PickupLocationInterface::IS_PICKUP_LOCATION_ACTIVE] ?? ''; } return $dataSource; diff --git a/InventoryInStorePickupAdminUi/composer.json b/InventoryInStorePickupAdminUi/composer.json index 4a3b97443645..8a92226501db 100644 --- a/InventoryInStorePickupAdminUi/composer.json +++ b/InventoryInStorePickupAdminUi/composer.json @@ -6,7 +6,9 @@ "magento/framework": "*", "magento/module-ui": "*", "magento/module-inventory-in-store-pickup-api": "*", - "magento/module-inventory-admin-ui": "*" + "magento/module-inventory-admin-ui": "*", + "magento/module-sales": "*", + "magento/module-backend": "*" }, "type": "magento2-module", "license": [ diff --git a/InventoryInStorePickupAdminUi/etc/adminhtml/routes.xml b/InventoryInStorePickupAdminUi/etc/adminhtml/routes.xml new file mode 100644 index 000000000000..c0d0cd0aaa11 --- /dev/null +++ b/InventoryInStorePickupAdminUi/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd"> + <router id="admin"> + <route id="sales" frontName="sales"> + <module name="Magento_InventoryInStorePickupAdminUi" before="Magento_Backend" /> + </route> + </router> +</config> diff --git a/InventoryInStorePickupAdminUi/etc/module.xml b/InventoryInStorePickupAdminUi/etc/module.xml index 0f5187cf90da..df41c09771b8 100644 --- a/InventoryInStorePickupAdminUi/etc/module.xml +++ b/InventoryInStorePickupAdminUi/etc/module.xml @@ -10,6 +10,8 @@ <sequence> <module name="Magento_InventoryInStorePickup" /> <module name="Magento_Shipping" /> + <module name="Magento_Sales" /> + <module name="Magento_Backend" /> </sequence> </module> </config> diff --git a/InventoryInStorePickupAdminUi/i18n/en_US.csv b/InventoryInStorePickupAdminUi/i18n/en_US.csv index 7ab098d01742..6305e7962fa0 100644 --- a/InventoryInStorePickupAdminUi/i18n/en_US.csv +++ b/InventoryInStorePickupAdminUi/i18n/en_US.csv @@ -1 +1,4 @@ -"Delivery Methods","Delivery Methods" \ No newline at end of file +"Delivery Methods","Delivery Methods" +"The customer have been notified and shipment created.","The customer have been notified and shipment created." +"We can't notify the customer right now.","We can't notify the customer right now." +"Notify Order is Ready for Pickup","Notify Order is Ready for Pickup" \ No newline at end of file diff --git a/InventoryInStorePickupAdminUi/view/adminhtml/layout/sales_order_view.xml b/InventoryInStorePickupAdminUi/view/adminhtml/layout/sales_order_view.xml new file mode 100644 index 000000000000..57c32390ad65 --- /dev/null +++ b/InventoryInStorePickupAdminUi/view/adminhtml/layout/sales_order_view.xml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> + <body> + <referenceContainer name="content"> + <block class="Magento\InventoryInStorePickupAdminUi\Block\Adminhtml\Order\View\ReadyForPickup" name="sales_order_ready_for_pickup"/> + </referenceContainer> + </body> +</page> diff --git a/InventoryInStorePickupApi/Api/Data/AddressInterface.php b/InventoryInStorePickupApi/Api/Data/AddressInterface.php new file mode 100644 index 000000000000..2e007c2f8fbd --- /dev/null +++ b/InventoryInStorePickupApi/Api/Data/AddressInterface.php @@ -0,0 +1,44 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Api\Data; + +/** + * Data interface for nearest pickup locations search request. + * + * @api + */ +interface AddressInterface +{ + /** + * Requested country + * + * @return string + */ + public function getCountry(): string; + + /** + * Requested postcode + * + * @return string|null + */ + public function getPostcode(): ?string; + + /** + * Requested region + * + * @return string|null + */ + public function getRegion(): ?string; + + /** + * Requested city + * + * @return string|null + */ + public function getCity(): ?string; +} diff --git a/InventoryInStorePickupApi/Api/Data/PickupLocationInterface.php b/InventoryInStorePickupApi/Api/Data/PickupLocationInterface.php index d1a3b73bdf27..11d801ef2147 100644 --- a/InventoryInStorePickupApi/Api/Data/PickupLocationInterface.php +++ b/InventoryInStorePickupApi/Api/Data/PickupLocationInterface.php @@ -7,11 +7,145 @@ namespace Magento\InventoryInStorePickupApi\Api\Data; +use Magento\Framework\Api\ExtensibleDataInterface; + /** - * @TODO Replace with autogenerated immutable realization. - * @see Please check issue for more details: https://github.com/magento-engcom/msi/issues/2126 + * Represents sources projection on In-Store Pickup context. + * Realisation must follow immutable DTO concept. + * Partial immutability done according to restriction of current Extension Attributes implementation. + * + * @api */ -interface PickupLocationInterface +interface PickupLocationInterface extends ExtensibleDataInterface { const IS_PICKUP_LOCATION_ACTIVE = 'is_pickup_location_active'; + + /** + * Get source code of Pickup Location + * + * @return string + */ + public function getSourceCode(): string; + + /** + * Get Pickup Location name + * + * @return string|null + */ + public function getName(): ?string; + + /** + * Get Pickup Location contact email + * + * @return string|null + */ + public function getEmail(): ?string; + + /** + * Get Fax contact info. + * + * @return string|null + */ + public function getFax(): ?string; + + /** + * Get Pickup Location contact name. + * + * @return string|null + */ + public function getContactName(): ?string; + + /** + * Get Pickup Location description. + * + * @return string|null + */ + public function getDescription(): ?string; + + /** + * Get Pickup Location latitude. + * + * @return float|null + */ + public function getLatitude(): ?float; + + /** + * Get Pickup Location longtitude. + * + * @return float|null + */ + public function getLongitude(): ?float; + + /** + * Get Pickup Location country ID. + * + * @return string|null + */ + public function getCountryId(): ?string; + + /** + * Get Pickup Location region ID. + * + * @return int|null + */ + public function getRegionId(): ?int; + + /** + * Get Pickup Location region. + * + * @return string|null + */ + public function getRegion(): ?string; + + /** + * Get Pickup Location city. + * + * @return string|null + */ + public function getCity(): ?string; + + /** + * Get Pickup Location street. + * + * @return string|null + */ + public function getStreet(): ?string; + + /** + * Get Pickup Location postcode. + * + * @return string|null + */ + public function getPostcode(): ?string; + + /** + * Get Pickup Location phone. + * + * @return string|null + */ + public function getPhone(): ?string; + + /** + * Get Pickup Location open hours. + * + * @return string[]|null + */ + public function getOpenHours(): ?array; + + /** + * Set Extension Attributes for Pickup Location. + * + * @param \Magento\InventoryInStorePickupApi\Api\Data\PickupLocationExtensionInterface|null $extensionAttributes + * + * @return void + */ + public function setExtensionAttributes(?PickupLocationExtensionInterface $extensionAttributes): void; + + /** + * Get Extension Attributes of Pickup Location. + * + * @return \Magento\InventoryInStorePickupApi\Api\Data\PickupLocationExtensionInterface|null + */ + public function getExtensionAttributes(): ?PickupLocationExtensionInterface; + } diff --git a/InventoryInStorePickupApi/Api/GetNearbyPickupLocationsInterface.php b/InventoryInStorePickupApi/Api/GetNearbyPickupLocationsInterface.php new file mode 100644 index 000000000000..aef5a4807f99 --- /dev/null +++ b/InventoryInStorePickupApi/Api/GetNearbyPickupLocationsInterface.php @@ -0,0 +1,26 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Api; + +use Magento\InventoryInStorePickupApi\Api\Data\AddressInterface; + +/** + * Find nearest Pickup Locations by requested address, radius, and affiliation to stock. + * + * @api + */ +interface GetNearbyPickupLocationsInterface +{ + /** + * @param AddressInterface $address + * @param int $radius + * @param int $stockId + * @return \Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface[] + */ + public function execute(AddressInterface $address, int $radius, int $stockId): array; +} diff --git a/InventoryInStorePickupApi/Api/GetNearbySourcesByPostcodeInterface.php b/InventoryInStorePickupApi/Api/GetNearbySourcesByPostcodeInterface.php deleted file mode 100644 index 0390ea23ab83..000000000000 --- a/InventoryInStorePickupApi/Api/GetNearbySourcesByPostcodeInterface.php +++ /dev/null @@ -1,28 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\InventoryInStorePickupApi\Api; - -/** - * Get nearby sources of a given zip code, based on the given radius in KM. - * - * @api - */ -interface GetNearbySourcesByPostcodeInterface -{ - /** - * Get nearby sources to a given postcode code, based on the given radius in KM - * - * @param string $country - * @param string $postcode - * @param int $radius - * @return \Magento\InventoryApi\Api\Data\SourceInterface[] - * - * @throws \Magento\Framework\Exception\NoSuchEntityException - */ - public function execute(string $country, string $postcode, int $radius): array; -} diff --git a/InventoryInStorePickupApi/Api/GetPickupLocationsAssignedToStockOrderedByPriorityInterface.php b/InventoryInStorePickupApi/Api/GetPickupLocationsAssignedToStockOrderedByPriorityInterface.php new file mode 100644 index 000000000000..b348d6382fb5 --- /dev/null +++ b/InventoryInStorePickupApi/Api/GetPickupLocationsAssignedToStockOrderedByPriorityInterface.php @@ -0,0 +1,22 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Api; + +/** + * Get Pickup Locations for requested scope, ordered by corresponded Source sort priority. + * + * @api + */ +interface GetPickupLocationsAssignedToStockOrderedByPriorityInterface +{ + /** + * @param int $stockId + * @return \Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface[] + */ + public function execute(int $stockId): array; +} diff --git a/InventoryInStorePickupApi/Api/IsOrderReadyForPickupInterface.php b/InventoryInStorePickupApi/Api/IsOrderReadyForPickupInterface.php new file mode 100644 index 000000000000..a80737f75aed --- /dev/null +++ b/InventoryInStorePickupApi/Api/IsOrderReadyForPickupInterface.php @@ -0,0 +1,22 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Api; + +/** + * Check if order is ready to be picked up by customer at the pickup location. + */ +interface IsOrderReadyForPickupInterface +{ + /** + * Check if order is ready to be picked up by customer at the pickup location. + * + * @param int $orderId + * @return bool + */ + public function execute(int $orderId): bool; +} diff --git a/InventoryInStorePickupApi/Api/NotifyOrderIsReadyForPickupInterface.php b/InventoryInStorePickupApi/Api/NotifyOrderIsReadyForPickupInterface.php new file mode 100644 index 000000000000..deb26a9f9e84 --- /dev/null +++ b/InventoryInStorePickupApi/Api/NotifyOrderIsReadyForPickupInterface.php @@ -0,0 +1,26 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Api; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; + +/** + * Send an email to the customer and ship the order to reserve pickup location`s QTY + */ +interface NotifyOrderIsReadyForPickupInterface +{ + /** + * Send an email to the customer and ship the order to reserve pickup location`s QTY. + * + * @param int $orderId + * @throws NoSuchEntityException + * @throws LocalizedException + */ + public function execute(int $orderId): void; +} diff --git a/InventoryInStorePickupApi/Model/GetNearbySourcesByPostcode.php b/InventoryInStorePickupApi/Model/GetNearbySourcesByPostcode.php deleted file mode 100644 index 9da390c98e80..000000000000 --- a/InventoryInStorePickupApi/Model/GetNearbySourcesByPostcode.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\InventoryInStorePickupApi\Model; - -use Magento\Framework\Exception\NoSuchEntityException; -use Magento\InventoryDistanceBasedSourceSelectionApi\Api\GetDistanceProviderCodeInterface; -use Magento\InventoryInStorePickupApi\Api\GetNearbySourcesByPostcodeInterface; - -/** - * Get nearby sources of a given zip code. - * - * @api - */ -class GetNearbySourcesByPostcode implements GetNearbySourcesByPostcodeInterface -{ - /** - * @var GetNearbySourcesByPostcodeInterface[] - */ - private $providers; - - /** - * @var GetDistanceProviderCodeInterface - */ - private $getDistanceProviderCode; - - /** - * @param GetDistanceProviderCodeInterface $getDistanceProviderCode - * @param GetNearbySourcesByPostcodeInterface[] $providers - */ - public function __construct( - GetDistanceProviderCodeInterface $getDistanceProviderCode, - array $providers - ) { - foreach ($providers as $providerCode => $provider) { - if (!($provider instanceof GetNearbySourcesByPostcodeInterface)) { - throw new \InvalidArgumentException( - sprintf( - "Nearby Sources provider %s must implement %s", - $providerCode, - GetNearbySourcesByPostcodeInterface::class - ) - ); - } - } - - $this->providers = $providers; - $this->getDistanceProviderCode = $getDistanceProviderCode; - } - - /** - * @inheritdoc - */ - public function execute(string $country, string $postcode, int $radius): array - { - $code = $this->getDistanceProviderCode->execute(); - if (!isset($this->providers[$code])) { - throw new NoSuchEntityException( - __('No such sources from postcode provider: %1', $code) - ); - } - - return $this->providers[$code]->execute($country, $postcode, $radius); - } -} diff --git a/InventoryInStorePickupApi/Model/Mapper.php b/InventoryInStorePickupApi/Model/Mapper.php new file mode 100644 index 000000000000..3afcd6b02691 --- /dev/null +++ b/InventoryInStorePickupApi/Model/Mapper.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Model; + +use Magento\InventoryApi\Api\Data\SourceInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; +use Magento\InventoryInStorePickupApi\Model\Mapper\CreateFromSourceInterface; + +/** + * Create projection of sources on In-Store Pickup context. + * Data transfer from source to projection will be done according to provided fields mapping. + * + * @api + */ +class Mapper +{ + /** + * Attributes map for projection. + * + * @var array + */ + private $map; + + /** + * @var CreateFromSourceInterface + */ + private $createFromSource; + + /** + * @param CreateFromSourceInterface $createFromSource + * @param array $map + */ + public function __construct( + CreateFromSourceInterface $createFromSource, + array $map = [] + ) { + $this->map = $map; + $this->createFromSource = $createFromSource; + } + + /** + * @param SourceInterface $source + * + * @return PickupLocationInterface + */ + public function map(SourceInterface $source): PickupLocationInterface + { + return $this->createFromSource->execute($source, $this->map); + } +} diff --git a/InventoryInStorePickupApi/Model/Mapper/CreateFromSourceInterface.php b/InventoryInStorePickupApi/Model/Mapper/CreateFromSourceInterface.php new file mode 100644 index 000000000000..ad079014a131 --- /dev/null +++ b/InventoryInStorePickupApi/Model/Mapper/CreateFromSourceInterface.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryInStorePickupApi\Model\Mapper; + +use Magento\InventoryApi\Api\Data\SourceInterface; +use Magento\InventoryInStorePickupApi\Api\Data\PickupLocationInterface; + +/** + * Create Pickup Location based on Source. + * Transport data from Source to Pickup Location according to provided mapping. + * + * @api + */ +interface CreateFromSourceInterface +{ + /** + * @param SourceInterface $source + * @param array $map May contains references to fields in extension attributes. + * Please use format 'extension_attributes.field_name' to do so. E.g. + * [ + * "extension_attributes.source_field" => "pickup_location_field" + * "extension_attributes.source_field" => "extension_attributes.pickup_location_extension_field", + * ] + * @return PickupLocationInterface + */ + public function execute(SourceInterface $source, array $map): PickupLocationInterface; +} diff --git a/InventoryInStorePickupApi/composer.json b/InventoryInStorePickupApi/composer.json index 36ddfc89dd99..5caac657d0d3 100644 --- a/InventoryInStorePickupApi/composer.json +++ b/InventoryInStorePickupApi/composer.json @@ -4,7 +4,7 @@ "require": { "php": "~7.1.3||~7.2.0", "magento/framework": "*", - "magento/module-inventory-distance-based-source-selection-api": "*" + "magento/module-inventory-api": "*" }, "type": "magento2-module", "license": [ diff --git a/InventoryInStorePickupApi/etc/acl.xml b/InventoryInStorePickupApi/etc/acl.xml new file mode 100644 index 000000000000..e44263c7ce9d --- /dev/null +++ b/InventoryInStorePickupApi/etc/acl.xml @@ -0,0 +1,20 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd"> + <acl> + <resources> + <resource id="Magento_Backend::admin"> + <resource id="Magento_Backend::stores"> + <resource id="Magento_InventoryApi::inventory"> + <resource id="Magento_InventoryApi::inStorePickup" title="In-Store Pickup" translate="title" sortOrder="20"/> + </resource> + </resource> + </resource> + </resources> + </acl> +</config> diff --git a/InventoryInStorePickupApi/etc/di.xml b/InventoryInStorePickupApi/etc/di.xml index c3dcdd78878b..8585922de9ba 100644 --- a/InventoryInStorePickupApi/etc/di.xml +++ b/InventoryInStorePickupApi/etc/di.xml @@ -5,8 +5,25 @@ * See COPYING.txt for license details. */ --> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> - <preference for="Magento\InventoryInStorePickupApi\Api\GetNearbySourcesByPostcodeInterface" - type="Magento\InventoryInStorePickupApi\Model\GetNearbySourcesByPostcode"/> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\InventoryInStorePickupApi\Model\Mapper"> + <arguments> + <argument name="map" xsi:type="array"> + <item name="source_code" xsi:type="string">source_code</item> + <item name="email" xsi:type="string">email</item> + <item name="fax" xsi:type="string">fax</item> + <item name="contact_name" xsi:type="string">contact_name</item> + <item name="description" xsi:type="string">description</item> + <item name="latitude" xsi:type="string">latitude</item> + <item name="longitude" xsi:type="string">longitude</item> + <item name="country_id" xsi:type="string">country_id</item> + <item name="region_id" xsi:type="string">region_id</item> + <item name="region" xsi:type="string">region</item> + <item name="city" xsi:type="string">city</item> + <item name="street" xsi:type="string">street</item> + <item name="postcode" xsi:type="string">postcode</item> + <item name="phone" xsi:type="string">phone</item> + </argument> + </arguments> + </type> </config> diff --git a/InventoryInStorePickupApi/etc/module.xml b/InventoryInStorePickupApi/etc/module.xml index fcd32a6f93d8..832b7ee3923a 100644 --- a/InventoryInStorePickupApi/etc/module.xml +++ b/InventoryInStorePickupApi/etc/module.xml @@ -6,5 +6,9 @@ */ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Magento_InventoryInStorePickupApi" setup_version="1.0.0" /> + <module name="Magento_InventoryInStorePickupApi" setup_version="1.0.0"> + <sequence> + <module name="Magento_InventoryApi"/> + </sequence> + </module> </config> diff --git a/InventoryInStorePickupApi/etc/webapi.xml b/InventoryInStorePickupApi/etc/webapi.xml new file mode 100644 index 000000000000..64d3787cb104 --- /dev/null +++ b/InventoryInStorePickupApi/etc/webapi.xml @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd"> + <route url="/V1/inventory/in-store-pickup/get-nearby-pickup-locations" method="GET"> + <service class="Magento\InventoryInStorePickupApi\Api\GetNearbyPickupLocationsInterface" method="execute"/> + <resources> + <resource ref="Magento_InventoryApi::inStorePickup"/> + </resources> + </route> + <route url="/V1/inventory/in-store-pickup/pickup-locations-assigned-to-stock-ordered-by-priority/:stockId" method="GET"> + <service class="Magento\InventoryInStorePickupApi\Api\GetPickupLocationsAssignedToStockOrderedByPriorityInterface" method="execute"/> + <resources> + <resource ref="Magento_InventoryApi::inStorePickup"/> + </resources> + </route> +</routes> diff --git a/InventoryLowQuantityNotificationAdminUi/i18n/en_US.csv b/InventoryLowQuantityNotificationAdminUi/i18n/en_US.csv index 3c7093256d0f..9ebabb94e648 100644 --- a/InventoryLowQuantityNotificationAdminUi/i18n/en_US.csv +++ b/InventoryLowQuantityNotificationAdminUi/i18n/en_US.csv @@ -9,3 +9,5 @@ Quantity,Quantity "Source Code","Source Code" "Notify Quantity","Notify Quantity" "Notify Quantity Use Default","Notify Quantity Use Default" +"Notify Qty","Notify Qty" +"Use Default","Use Default" diff --git a/InventoryLowQuantityNotificationAdminUi/view/adminhtml/ui_component/product_form.xml b/InventoryLowQuantityNotificationAdminUi/view/adminhtml/ui_component/product_form.xml index f5aaa92a2bc8..1e8336c57f95 100644 --- a/InventoryLowQuantityNotificationAdminUi/view/adminhtml/ui_component/product_form.xml +++ b/InventoryLowQuantityNotificationAdminUi/view/adminhtml/ui_component/product_form.xml @@ -6,7 +6,7 @@ */ --> <form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd"> - <fieldset name="sources"> + <container name="sources"> <dynamicRows name="assigned_sources" component="Magento_Ui/js/dynamic-rows/dynamic-rows-grid"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> @@ -18,47 +18,57 @@ </item> </argument> <container name="record" component="Magento_Ui/js/dynamic-rows/record"> - <field name="notify_stock_qty" formElement="input" sortOrder="60" component="Magento_InventoryLowQuantityNotificationAdminUi/js/components/notify-stock-qty"> - <settings> - <dataType>text</dataType> - <dataScope>notify_stock_qty</dataScope> - <label translate="true">Notify Quantity</label> - <imports> - <link name="notifyStockQtyUseDefault">${$.parentName}.notify_stock_qty_use_default:checked</link> - <link name="manageStock">!${ $.provider }:data.product.stock_data.manage_stock</link> - </imports> - </settings> - </field> - <field name="notify_stock_qty_use_default" component="Magento_InventoryLowQuantityNotificationAdminUi/js/components/use-config-settings" formElement="checkbox" sortOrder="70"> + <container component="Magento_Ui/js/form/components/group" sortOrder="60"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> - <item name="valueFromConfig" xsi:type="object">Magento\CatalogInventory\Model\Source\StockConfiguration</item> - <item name="keyInConfiguration" xsi:type="string">notify_stock_qty</item> - <item name="default" xsi:type="number">1</item> + <item name="label" xsi:type="string" translate="true">Notify Qty</item> + <item name="showLabel" xsi:type="boolean">false</item> </item> </argument> - <settings> - <dataScope>notify_stock_qty_use_default</dataScope> - <label translate="true">Notify Quantity Use Default</label> - <links> - <link name="linkedValue">${$.provider}:${$.parentScope}.notify_stock_qty</link> - </links> - <imports> - <link name="disabled">!${ $.provider }:data.product.stock_data.manage_stock</link> - </imports> - </settings> - <formElements> - <checkbox class="Magento\InventoryLowQuantityNotificationAdminUi\Ui\Component\Product\Form\Element\UseConfigSettings"> - <settings> - <valueMap> - <map name="false" xsi:type="string">0</map> - <map name="true" xsi:type="string">1</map> - </valueMap> - </settings> - </checkbox> - </formElements> - </field> + <field name="notify_stock_qty" formElement="input" sortOrder="60" + component="Magento_InventoryLowQuantityNotificationAdminUi/js/components/notify-stock-qty"> + <settings> + <labelVisible>false</labelVisible> + <dataType>text</dataType> + <dataScope>notify_stock_qty</dataScope> + <imports> + <link name="notifyStockQtyUseDefault">${$.parentName}.notify_stock_qty_use_default:checked</link> + <link name="manageStock">!${ $.provider }:data.product.stock_data.manage_stock</link> + </imports> + </settings> + </field> + <field name="notify_stock_qty_use_default" component="Magento_InventoryLowQuantityNotificationAdminUi/js/components/use-config-settings" formElement="checkbox" sortOrder="70"> + <argument name="data" xsi:type="array"> + <item name="config" xsi:type="array"> + <item name="valueFromConfig" xsi:type="object">Magento\CatalogInventory\Model\Source\StockConfiguration</item> + <item name="keyInConfiguration" xsi:type="string">notify_stock_qty</item> + <item name="default" xsi:type="number">1</item> + </item> + </argument> + <settings> + <labelVisible>false</labelVisible> + <dataScope>notify_stock_qty_use_default</dataScope> + <links> + <link name="linkedValue">${$.provider}:${$.parentScope}.notify_stock_qty</link> + </links> + <imports> + <link name="disabled">!${ $.provider }:data.product.stock_data.manage_stock</link> + </imports> + </settings> + <formElements> + <checkbox class="Magento\InventoryLowQuantityNotificationAdminUi\Ui\Component\Product\Form\Element\UseConfigSettings"> + <settings> + <description translate="true">Use Default</description> + <valueMap> + <map name="false" xsi:type="string">0</map> + <map name="true" xsi:type="string">1</map> + </valueMap> + </settings> + </checkbox> + </formElements> + </field> + </container> </container> </dynamicRows> - </fieldset> + </container> </form> diff --git a/InventoryReservationCli/Command/CreateCompensations.php b/InventoryReservationCli/Command/CreateCompensations.php new file mode 100644 index 000000000000..cc4428e3c833 --- /dev/null +++ b/InventoryReservationCli/Command/CreateCompensations.php @@ -0,0 +1,153 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Command; + +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Validation\ValidationException; +use Magento\InventoryReservationCli\Command\Input\GetCommandlineStandardInput; +use Magento\InventoryReservationCli\Command\Input\GetReservationFromCompensationArgument; +use Magento\InventoryReservationsApi\Model\AppendReservationsInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Create compensations for detected inconsistencies + * + * This command may be used to simplify migrations from Magento versions without new Inventory or to track down + * incorrect behavior of customizations. + */ +class CreateCompensations extends Command +{ + /** + * @var GetCommandlineStandardInput + */ + private $getCommandlineStandardInput; + + /** + * @var GetReservationFromCompensationArgument + */ + private $getReservationFromCompensationArgument; + + /** + * @var AppendReservationsInterface + */ + private $appendReservations; + + /** + * @param GetCommandlineStandardInput $getCommandlineStandardInput + * @param GetReservationFromCompensationArgument $getReservationFromCompensationArgument + * @param AppendReservationsInterface $appendReservations + */ + public function __construct( + GetCommandlineStandardInput $getCommandlineStandardInput, + GetReservationFromCompensationArgument $getReservationFromCompensationArgument, + AppendReservationsInterface $appendReservations + ) { + parent::__construct(); + $this->getCommandlineStandardInput = $getCommandlineStandardInput; + $this->getReservationFromCompensationArgument = $getReservationFromCompensationArgument; + $this->appendReservations = $appendReservations; + } + + /** + * @inheritdoc + */ + protected function configure() + { + $this + ->setName('inventory:reservation:create-compensations') + ->setDescription('Create reservations by provided compensation arguments') + ->addArgument( + 'compensations', + InputArgument::IS_ARRAY, + 'List of compensation arguments in format "<ORDER_INCREMENT_ID>:<SKU>:<QUANTITY>:<STOCK-ID>"' + ) + ->addOption( + 'raw', + 'r', + InputOption::VALUE_NONE, + 'Raw output' + ); + + parent::configure(); + } + + /** + * @param InputInterface $input + * @return array + * @throws InvalidArgumentException + */ + private function getCompensationsArguments(InputInterface $input): array + { + $compensationArguments = $input->getArgument('compensations'); + + if (empty($compensationArguments)) { + $compensationArguments = $this->getCommandlineStandardInput->execute(); + } + + if (empty($compensationArguments)) { + throw new InvalidArgumentException('A list of compensations needs to be defined as argument or STDIN.'); + } + + return $compensationArguments; + } + + /** + * {@inheritdoc} + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + * @throws ValidationException + * @throws InputException + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('<info>Following reservations were created:</info>'); + + $hasErrors = false; + foreach ($this->getCompensationsArguments($input) as $compensationsArgument) { + try { + $compensation = $this->getReservationFromCompensationArgument->execute($compensationsArgument); + $this->appendReservations->execute([$compensation]); + $output->writeln( + sprintf( + ' - Product <comment>%s</comment> was compensated by ' + . '<comment>%+f</comment> for stock <comment>%s</comment>', + $compensation->getSku(), + -$compensation->getQuantity(), + $compensation->getStockId() + ) + ); + } catch (CouldNotSaveException $exception) { + $hasErrors = true; + $output->writeln(sprintf(' - <error>%s</error>', $exception->getMessage())); + } catch (InvalidArgumentException $exception) { + $hasErrors = true; + $output->writeln(sprintf( + ' - <error>Error while parsing argument "%s". %s</error>', + $compensationsArgument, + $exception->getMessage() + )); + } catch (\Exception $exception) { + $output->writeln(sprintf( + ' - <error>Argument "%s" caused exception "%s"</error>', + $compensationsArgument, + $exception->getMessage() + )); + } + } + + return $hasErrors ? 1 : 0; + } +} diff --git a/InventoryReservationCli/Command/Input/GetCommandlineStandardInput.php b/InventoryReservationCli/Command/Input/GetCommandlineStandardInput.php new file mode 100644 index 000000000000..9adfa0dd6661 --- /dev/null +++ b/InventoryReservationCli/Command/Input/GetCommandlineStandardInput.php @@ -0,0 +1,31 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Command\Input; + +/** + * Fetches standard input for cli commands and retrieves as array + */ +class GetCommandlineStandardInput +{ + /** + * @return array + */ + public function execute(): array + { + $values = []; + $handle = fopen('php://stdin', 'r'); + if ($handle) { + while ($line = fgets($handle)) { + $values[] = trim($line); + } + fclose($handle); + } + + return array_filter($values); + } +} diff --git a/InventoryReservationCli/Command/Input/GetReservationFromCompensationArgument.php b/InventoryReservationCli/Command/Input/GetReservationFromCompensationArgument.php new file mode 100644 index 000000000000..c52c4fc318c5 --- /dev/null +++ b/InventoryReservationCli/Command/Input/GetReservationFromCompensationArgument.php @@ -0,0 +1,89 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Command\Input; + +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\Validation\ValidationException; +use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface; +use Magento\InventoryReservationsApi\Model\ReservationInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Symfony\Component\Console\Exception\InvalidArgumentException; + +/** + * Builds reservation model from given compensation input argument + */ +class GetReservationFromCompensationArgument +{ + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var ReservationBuilderInterface + */ + private $reservationBuilder; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @param OrderRepositoryInterface $orderRepository + * @param ReservationBuilderInterface $reservationBuilder + * @param SerializerInterface $serializer + */ + public function __construct( + OrderRepositoryInterface $orderRepository, + ReservationBuilderInterface $reservationBuilder, + SerializerInterface $serializer + ) { + $this->orderRepository = $orderRepository; + $this->reservationBuilder = $reservationBuilder; + $this->serializer = $serializer; + } + + /** + * @param string $argument + * @return array + * @throws InvalidArgumentException + */ + private function parseArgument(string $argument): array + { + $pattern = '/(?P<increment_id>.*):(?P<sku>.*):(?P<quantity>.*):(?P<stock_id>.*)/'; + if (preg_match($pattern, $argument, $match)) { + return $match; + } + + throw new InvalidArgumentException(sprintf('Given argument does not match pattern "%s"', $pattern)); + } + + /** + * @param string $argument + * @return ReservationInterface + * @throws InvalidArgumentException + * @throws ValidationException + */ + public function execute(string $argument): ReservationInterface + { + $argumentParts = $this->parseArgument($argument); + $order = $this->orderRepository->get($argumentParts['increment_id']); + + return $this->reservationBuilder + ->setSku((string)$argumentParts['sku']) + ->setQuantity((float)$argumentParts['quantity']) + ->setStockId((int)$argumentParts['stock_id']) + ->setMetadata($this->serializer->serialize([ + 'event_type' => 'manual_compensation', + 'object_type' => 'order', + 'object_id' => $order->getEntityId(), + ])) + ->build(); + } +} diff --git a/InventoryReservationCli/Command/ShowInconsistencies.php b/InventoryReservationCli/Command/ShowInconsistencies.php new file mode 100644 index 000000000000..74daab27f4e5 --- /dev/null +++ b/InventoryReservationCli/Command/ShowInconsistencies.php @@ -0,0 +1,178 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Command; + +use Magento\InventoryReservationCli\Model\GetSalableQuantityInconsistencies; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterCompleteOrders; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterIncompleteOrders; +use Magento\Sales\Model\Order; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Outputs a list of uncompensated reservations linked to the orders + * + * This command may be used to simplify migrations from Magento versions without new Inventory or to track down + * incorrect behavior of customizations. + */ +class ShowInconsistencies extends Command +{ + /** + * @var GetSalableQuantityInconsistencies + */ + private $getSalableQuantityInconsistencies; + + /** + * @var FilterCompleteOrders + */ + private $filterCompleteOrders; + + /** + * @var FilterIncompleteOrders + */ + private $filterIncompleteOrders; + + /** + * @param GetSalableQuantityInconsistencies $getSalableQuantityInconsistencies + * @param FilterCompleteOrders $filterCompleteOrders + * @param FilterIncompleteOrders $filterIncompleteOrders + */ + public function __construct( + GetSalableQuantityInconsistencies $getSalableQuantityInconsistencies, + FilterCompleteOrders $filterCompleteOrders, + FilterIncompleteOrders $filterIncompleteOrders + ) { + parent::__construct(); + $this->getSalableQuantityInconsistencies = $getSalableQuantityInconsistencies; + $this->filterCompleteOrders = $filterCompleteOrders; + $this->filterIncompleteOrders = $filterIncompleteOrders; + } + + /** + * @inheritdoc + */ + protected function configure() + { + $this + ->setName('inventory:reservation:list-inconsistencies') + ->setDescription('Show all orders and products with salable quantity inconsistencies') + ->addOption( + 'complete-orders', + 'c', + InputOption::VALUE_NONE, + 'Show only inconsistencies for complete orders' + ) + ->addOption( + 'incomplete-orders', + 'i', + InputOption::VALUE_NONE, + 'Show only inconsistencies for incomplete orders' + ) + ->addOption( + 'raw', + 'r', + InputOption::VALUE_NONE, + 'Raw output' + ); + + parent::configure(); + } + + /** + * Format output + * + * @param OutputInterface $output + * @param SalableQuantityInconsistency[] $inconsistencies + */ + private function prettyOutput(OutputInterface $output, array $inconsistencies): void + { + $output->writeln('<info>Inconsistencies found on following entries:</info>'); + + /** @var Order $order */ + foreach ($inconsistencies as $inconsistency) { + $inconsistentItems = $inconsistency->getItems(); + + $output->writeln(sprintf( + 'Order <comment>%s</comment>:', + $inconsistency->getOrder()->getIncrementId() + )); + + foreach ($inconsistentItems as $sku => $qty) { + $output->writeln( + sprintf( + ' - Product <comment>%s</comment> should be compensated by ' + . '<comment>%+f</comment> for stock <comment>%s</comment>', + $sku, + -$qty, + $inconsistency->getStockId() + ) + ); + } + } + } + + /** + * Output without formatting + * + * @param OutputInterface $output + * @param SalableQuantityInconsistency[] $inconsistencies + */ + private function rawOutput(OutputInterface $output, array $inconsistencies): void + { + /** @var Order $order */ + foreach ($inconsistencies as $inconsistency) { + $inconsistentItems = $inconsistency->getItems(); + + foreach ($inconsistentItems as $sku => $qty) { + $output->writeln( + sprintf( + '%s:%s:%f:%s', + $inconsistency->getOrder()->getIncrementId(), + $sku, + -$qty, + $inconsistency->getStockId() + ) + ); + } + } + } + + /** + * {@inheritdoc} + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + $inconsistencies = $this->getSalableQuantityInconsistencies->execute(); + + if ($input->getOption('complete-orders')) { + $inconsistencies = $this->filterCompleteOrders->execute($inconsistencies); + } elseif ($input->getOption('incomplete-orders')) { + $inconsistencies = $this->filterIncompleteOrders->execute($inconsistencies); + } + + if (empty($inconsistencies)) { + $output->writeln('<info>No order inconsistencies were found</info>'); + return 0; + } + + if ($input->getOption('raw')) { + $this->rawOutput($output, $inconsistencies); + } else { + $this->prettyOutput($output, $inconsistencies); + } + + return -1; + } +} diff --git a/InventoryReservationCli/Model/GetCompleteOrderStatusList.php b/InventoryReservationCli/Model/GetCompleteOrderStatusList.php new file mode 100644 index 000000000000..6aaf4b75ebd3 --- /dev/null +++ b/InventoryReservationCli/Model/GetCompleteOrderStatusList.php @@ -0,0 +1,30 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model; + +use Magento\Sales\Model\Order; + +/** + * Provides list of order status for the complete state + */ +class GetCompleteOrderStatusList +{ + /** + * Provides list of order status for the complete state + * + * @return array + */ + public function execute(): array + { + return [ + Order::STATE_COMPLETE, + Order::STATE_CLOSED, + Order::STATE_CANCELED + ]; + } +} diff --git a/InventoryReservationCli/Model/GetOrdersInFinalState.php b/InventoryReservationCli/Model/GetOrdersInFinalState.php new file mode 100644 index 000000000000..c807737390ea --- /dev/null +++ b/InventoryReservationCli/Model/GetOrdersInFinalState.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; + +/** + * Get list of orders in any of the final states (Complete, Closed, Canceled). + */ +class GetOrdersInFinalState +{ + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var GetCompleteOrderStatusList + */ + private $getCompleteOrderStatusList; + + /** + * @param OrderRepositoryInterface $orderRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param GetCompleteOrderStatusList $getCompleteOrderStatusList + */ + public function __construct( + OrderRepositoryInterface $orderRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + GetCompleteOrderStatusList $getCompleteOrderStatusList + ) { + $this->orderRepository = $orderRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->getCompleteOrderStatusList = $getCompleteOrderStatusList; + } + + /** + * Get list of orders in any of the final states (Complete, Closed, Canceled). + * + * @param array $orderIds + * @return OrderInterface[] + */ + public function execute(array $orderIds): array + { + /** @var SearchCriteriaInterface $filter */ + $filter = $this->searchCriteriaBuilder + ->addFilter('entity_id', $orderIds, 'in') + ->addFilter('state', $this->getCompleteOrderStatusList->execute(), 'in') + ->create(); + + $orderSearchResult = $this->orderRepository->getList($filter); + return $orderSearchResult->getItems(); + } +} diff --git a/InventoryReservationCli/Model/GetOrdersInNotFinalState.php b/InventoryReservationCli/Model/GetOrdersInNotFinalState.php new file mode 100644 index 000000000000..b39f14491a5f --- /dev/null +++ b/InventoryReservationCli/Model/GetOrdersInNotFinalState.php @@ -0,0 +1,66 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; + +/** + * Get list of orders, which are not in any of the final states (Complete, Closed, Canceled). + */ +class GetOrdersInNotFinalState +{ + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var GetCompleteOrderStatusList + */ + private $getCompleteOrderStatusList; + + /** + * @param OrderRepositoryInterface $orderRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param GetCompleteOrderStatusList $getCompleteOrderStatusList + */ + public function __construct( + OrderRepositoryInterface $orderRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + GetCompleteOrderStatusList $getCompleteOrderStatusList + ) { + $this->orderRepository = $orderRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->getCompleteOrderStatusList = $getCompleteOrderStatusList; + } + + /** + * Get list of orders + * + * @return OrderInterface[] + */ + public function execute(): array + { + /** @var SearchCriteriaInterface $filter */ + $filter = $this->searchCriteriaBuilder + ->addFilter('state', $this->getCompleteOrderStatusList->execute(), 'nin') + ->create(); + + $orderSearchResult = $this->orderRepository->getList($filter); + return $orderSearchResult->getItems(); + } +} diff --git a/InventoryReservationCli/Model/GetSalableQuantityInconsistencies.php b/InventoryReservationCli/Model/GetSalableQuantityInconsistencies.php new file mode 100644 index 000000000000..e7c1e78b76e6 --- /dev/null +++ b/InventoryReservationCli/Model/GetSalableQuantityInconsistencies.php @@ -0,0 +1,111 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Validation\ValidationException; +use Magento\InventoryConfigurationApi\Exception\SkuIsNotAssignedToStockException; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\AddCompletedOrdersToForUnresolvedReservations; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\AddExistingReservations; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\AddExpectedReservations; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\Collector; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\CollectorFactory; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterExistingOrders; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterManagedStockProducts; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterUnresolvedReservations; + +/** + * Filter orders for missing initial reservation + */ +class GetSalableQuantityInconsistencies +{ + /** + * @var CollectorFactory + */ + private $collectorFactory; + + /** + * @var AddExpectedReservations + */ + private $addExpectedReservations; + + /** + * @var AddExistingReservations + */ + private $addExistingReservations; + + /** + * @var AddCompletedOrdersToForUnresolvedReservations + */ + private $addCompletedOrdersToUnresolved; + + /** + * @var FilterExistingOrders + */ + private $filterExistingOrders; + + /** + * @var FilterUnresolvedReservations + */ + private $filterUnresolvedReservations; + + /** + * @var FilterManagedStockProducts + */ + private $filterManagedStockProducts; + + /** + * @param CollectorFactory $collectorFactory + * @param AddExpectedReservations $addExpectedReservations + * @param AddExistingReservations $addExistingReservations + * @param AddCompletedOrdersToForUnresolvedReservations $addCompletedOrdersToUnresolved + * @param FilterExistingOrders $filterExistingOrders + * @param FilterUnresolvedReservations $filterUnresolvedReservations + * @param FilterManagedStockProducts $filterManagedStockProducts + */ + public function __construct( + CollectorFactory $collectorFactory, + AddExpectedReservations $addExpectedReservations, + AddExistingReservations $addExistingReservations, + AddCompletedOrdersToForUnresolvedReservations $addCompletedOrdersToUnresolved, + FilterExistingOrders $filterExistingOrders, + FilterUnresolvedReservations $filterUnresolvedReservations, + FilterManagedStockProducts $filterManagedStockProducts + ) { + $this->collectorFactory = $collectorFactory; + $this->addExpectedReservations = $addExpectedReservations; + $this->addExistingReservations = $addExistingReservations; + $this->addCompletedOrdersToUnresolved = $addCompletedOrdersToUnresolved; + $this->filterExistingOrders = $filterExistingOrders; + $this->filterUnresolvedReservations = $filterUnresolvedReservations; + $this->filterManagedStockProducts = $filterManagedStockProducts; + } + + /** + * Filter orders for missing initial reservation + * @return SalableQuantityInconsistency[] + * @throws ValidationException + * @throws LocalizedException + * @throws SkuIsNotAssignedToStockException + */ + public function execute(): array + { + /** @var Collector $collector */ + $collector = $this->collectorFactory->create(); + $this->addExpectedReservations->execute($collector); + $this->addExistingReservations->execute($collector); + $this->addCompletedOrdersToUnresolved->execute($collector); + + $items = $collector->getItems(); + $items = $this->filterManagedStockProducts->execute($items); + $items = $this->filterUnresolvedReservations->execute($items); + $items = $this->filterExistingOrders->execute($items); + + return $items; + } +} diff --git a/InventoryReservationCli/Model/ResourceModel/GetReservationsList.php b/InventoryReservationCli/Model/ResourceModel/GetReservationsList.php new file mode 100644 index 000000000000..2cf3e0d64b3c --- /dev/null +++ b/InventoryReservationCli/Model/ResourceModel/GetReservationsList.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; + +/** + * Load reservations from database. + */ +class GetReservationsList +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct ( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Load reservations from database. + * + * @return array + */ + public function execute(): array + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName('inventory_reservation'); + + $query = $connection + ->select() + ->from($tableName); + return $connection->fetchAll($query); + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency.php b/InventoryReservationCli/Model/SalableQuantityInconsistency.php new file mode 100644 index 000000000000..e51c84d37f94 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency.php @@ -0,0 +1,113 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model; + +use Magento\Sales\Api\Data\OrderInterface; + +/** + * Filter orders for missing initial reservation + */ +class SalableQuantityInconsistency +{ + /** + * @var OrderInterface + */ + private $order; + + /**+ + * @var int + */ + private $objectId; + + /**+ + * @var int + */ + private $stockId; + + /** + * List of SKUs and quantity + * @var array + */ + private $items = []; + + /** + * @return OrderInterface|null + */ + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + /** + * @param OrderInterface $order + */ + public function setOrder(OrderInterface $order): void + { + $this->order = $order; + } + + /** + * @return int + */ + public function getObjectId(): int + { + return $this->objectId; + } + + /** + * @param int $objectId + */ + public function setObjectId(int $objectId): void + { + $this->objectId = $objectId; + } + + /** + * @param string $sku + * @param float $qty + */ + public function addItemQty(string $sku, float $qty): void + { + if (!isset($this->items[$sku])) { + $this->items[$sku] = 0.0; + } + $this->items[$sku] += $qty; + } + + /** + * @return array + */ + public function getItems(): array + { + return $this->items; + } + + /** + * @param array $items + */ + public function setItems(array $items): void + { + $this->items = $items; + } + + /** + * @return int + */ + public function getStockId(): int + { + return $this->stockId; + } + + /** + * @param int $stockId + */ + public function setStockId(int $stockId): void + { + $this->stockId = $stockId; + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/AddCompletedOrdersToForUnresolvedReservations.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddCompletedOrdersToForUnresolvedReservations.php new file mode 100644 index 000000000000..384c60bb4e6a --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddCompletedOrdersToForUnresolvedReservations.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\InventoryReservationCli\Model\GetOrdersInFinalState; + +/** + * Match completed orders with unresolved reservations + */ +class AddCompletedOrdersToForUnresolvedReservations +{ + /** + * @var GetOrdersInFinalState + */ + private $getOrdersInFinalState; + + /** + * @param GetOrdersInFinalState $getOrdersInFinalState + */ + public function __construct( + GetOrdersInFinalState $getOrdersInFinalState + ) { + $this->getOrdersInFinalState = $getOrdersInFinalState; + } + + /** + * Remove all entries without order + * @param Collector $collector + */ + public function execute(Collector $collector): void + { + $inconsistencies = $collector->getItems(); + + $orderIds = []; + foreach ($inconsistencies as $inconsistency) { + $orderIds[] = $inconsistency->getObjectId(); + } + + foreach ($this->getOrdersInFinalState->execute($orderIds) as $order) { + $collector->addOrder($order); + } + + $collector->setItems($inconsistencies); + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExistingReservations.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExistingReservations.php new file mode 100644 index 000000000000..52413dd21217 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExistingReservations.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\Validation\ValidationException; +use Magento\InventoryReservationCli\Model\ResourceModel\GetReservationsList; +use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface; + +/** + * Add existing reservations + */ +class AddExistingReservations +{ + /** + * @var GetReservationsList + */ + private $getReservationsList; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @var ReservationBuilderInterface + */ + private $reservationBuilder; + + /** + * @param GetReservationsList $getReservationsList + * @param SerializerInterface $serializer + * @param ReservationBuilderInterface $reservationBuilder + */ + public function __construct( + GetReservationsList $getReservationsList, + SerializerInterface $serializer, + ReservationBuilderInterface $reservationBuilder + ) { + $this->getReservationsList = $getReservationsList; + $this->serializer = $serializer; + $this->reservationBuilder = $reservationBuilder; + } + + /** + * Add existing reservations + * @param Collector $collector + * @throws ValidationException + */ + public function execute(Collector $collector): void + { + $reservationList = $this->getReservationsList->execute(); + foreach ($reservationList as $reservation) { + /** @var array $metadata */ + $metadata = $this->serializer->unserialize($reservation['metadata']); + $orderType = $metadata['object_type']; + + if ($orderType !== 'order') { + continue; + } + + $reservation = $this->reservationBuilder + ->setMetadata($reservation['metadata']) + ->setStockId((int)$reservation['stock_id']) + ->setSku($reservation['sku']) + ->setQuantity((float)$reservation['quantity']) + ->build(); + + $collector->addReservation($reservation); + } + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExpectedReservations.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExpectedReservations.php new file mode 100644 index 000000000000..b1000af9638b --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/AddExpectedReservations.php @@ -0,0 +1,83 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\Validation\ValidationException; +use Magento\InventoryReservationCli\Model\GetOrdersInNotFinalState; +use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface; +use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface; + +/** + * Add expected reservations by current incomplete orders + */ +class AddExpectedReservations +{ + /** + * @var GetOrdersInNotFinalState + */ + private $getOrdersInNotFinalState; + + /** + * @var ReservationBuilderInterface + */ + private $reservationBuilder; + + /** + * @var StockByWebsiteIdResolverInterface + */ + private $stockByWebsiteIdResolver; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @param GetOrdersInNotFinalState $getOrdersInNotFinalState + * @param ReservationBuilderInterface $reservationBuilder + * @param StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver + * @param SerializerInterface $serializer + */ + public function __construct( + GetOrdersInNotFinalState $getOrdersInNotFinalState, + ReservationBuilderInterface $reservationBuilder, + StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver, + SerializerInterface $serializer + ) { + $this->getOrdersInNotFinalState = $getOrdersInNotFinalState; + $this->reservationBuilder = $reservationBuilder; + $this->stockByWebsiteIdResolver = $stockByWebsiteIdResolver; + $this->serializer = $serializer; + } + + /** + * Add expected reservations by current incomplete orders + * @param Collector $collector + * @throws ValidationException + */ + public function execute(Collector $collector): void + { + foreach ($this->getOrdersInNotFinalState->execute() as $order) { + $websiteId = (int)$order->getStore()->getWebsiteId(); + $stockId = (int)$this->stockByWebsiteIdResolver->execute((int)$websiteId)->getStockId(); + + foreach ($order->getItems() as $item) { + $reservation = $this->reservationBuilder + ->setSku($item->getSku()) + ->setQuantity((float)$item->getQtyOrdered()) + ->setStockId($stockId) + ->setMetadata($this->serializer->serialize(['object_id' => (int)$order->getEntityId()])) + ->build(); + + $collector->addReservation($reservation); + $collector->addOrder($order); + } + } + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/Collector.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/Collector.php new file mode 100644 index 000000000000..8544647aacf8 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/Collector.php @@ -0,0 +1,108 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\Framework\Serialize\SerializerInterface; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistencyFactory; +use Magento\InventoryReservationsApi\Model\ReservationInterface; +use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface; +use Magento\Sales\Api\Data\OrderInterface; + +/** + * Collects all existing and missing reservations in order to calculate inconsistency + */ +class Collector +{ + /** + * @var SalableQuantityInconsistency[] + */ + private $items = []; + + /** + * @var \Magento\InventoryReservationCli\Model\SalableQuantityInconsistencyFactory + */ + private $salableQuantityInconsistencyFactory; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @var StockByWebsiteIdResolverInterface + */ + private $stockByWebsiteIdResolver; + + /** + * @param SalableQuantityInconsistencyFactory $salableQuantityInconsistencyFactory + * @param SerializerInterface $serializer + * @param StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver + */ + public function __construct( + SalableQuantityInconsistencyFactory $salableQuantityInconsistencyFactory, + SerializerInterface $serializer, + StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver + ) { + $this->salableQuantityInconsistencyFactory = $salableQuantityInconsistencyFactory; + $this->serializer = $serializer; + $this->stockByWebsiteIdResolver = $stockByWebsiteIdResolver; + } + + /** + * @param ReservationInterface $reservation + */ + public function addReservation(ReservationInterface $reservation): void + { + $metadata = $this->serializer->unserialize($reservation->getMetadata()); + $objectId = $metadata['object_id']; + $stockId = $reservation->getStockId(); + $key = $objectId . '-' . $stockId; + + if (!isset($this->items[$key])) { + $this->items[$key] = $this->salableQuantityInconsistencyFactory->create(); + } + + $this->items[$key]->setObjectId((int)$objectId); + $this->items[$key]->setStockId((int)$stockId); + $this->items[$key]->addItemQty($reservation->getSku(), $reservation->getQuantity()); + } + + /** + * @param OrderInterface $order + */ + public function addOrder(OrderInterface $order): void + { + $objectId = $order->getEntityId(); + $websiteId = (int)$order->getStore()->getWebsiteId(); + $stockId = (int)$this->stockByWebsiteIdResolver->execute((int)$websiteId)->getStockId(); + $key = $objectId . '-' . $stockId; + + if (!isset($this->items[$key])) { + $this->items[$key] = $this->salableQuantityInconsistencyFactory->create(); + } + + $this->items[$key]->setOrder($order); + } + + /** + * @return SalableQuantityInconsistency[] + */ + public function getItems(): array + { + return $this->items; + } + + /** + * @param SalableQuantityInconsistency[] $items + */ + public function setItems(array $items): void + { + $this->items = $items; + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterCompleteOrders.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterCompleteOrders.php new file mode 100644 index 000000000000..10ee9f2d8d70 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterCompleteOrders.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\InventoryReservationCli\Model\GetCompleteOrderStatusList; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +/** + * Remove all reservations with complete state + */ +class FilterCompleteOrders +{ + /** + * @var GetCompleteOrderStatusList + */ + private $getCompleteOrderStatusList; + + /** + * @param GetCompleteOrderStatusList $getCompleteOrderStatusList + */ + public function __construct( + GetCompleteOrderStatusList $getCompleteOrderStatusList + ) { + $this->getCompleteOrderStatusList = $getCompleteOrderStatusList; + } + + /** + * Remove all reservations with complete state + * + * @param SalableQuantityInconsistency[] $inconsistencies + * @return SalableQuantityInconsistency[] + */ + public function execute(array $inconsistencies): array + { + return array_filter( + $inconsistencies, + function (SalableQuantityInconsistency $inconsistency) { + return in_array($inconsistency->getOrder()->getStatus(), $this->getCompleteOrderStatusList->execute()); + } + ); + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterExistingOrders.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterExistingOrders.php new file mode 100644 index 000000000000..9ee78b34fc32 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterExistingOrders.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +/** + * Remove all reservations without matching order + */ +class FilterExistingOrders +{ + /** + * Remove all reservations without matching order + * + * @param SalableQuantityInconsistency[] $inconsistencies + * @return SalableQuantityInconsistency[] + */ + public function execute(array $inconsistencies): array + { + return array_filter( + $inconsistencies, + function (SalableQuantityInconsistency $inconsistency) { + return (bool)$inconsistency->getOrder(); + } + ); + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterIncompleteOrders.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterIncompleteOrders.php new file mode 100644 index 000000000000..bf11ef4df681 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterIncompleteOrders.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\InventoryReservationCli\Model\GetCompleteOrderStatusList; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +/** + * Remove all reservations with incomplete state + */ +class FilterIncompleteOrders +{ + /** + * @var GetCompleteOrderStatusList + */ + private $getCompleteOrderStatusList; + + /** + * @param GetCompleteOrderStatusList $getCompleteOrderStatusList + */ + public function __construct( + GetCompleteOrderStatusList $getCompleteOrderStatusList + ) { + $this->getCompleteOrderStatusList = $getCompleteOrderStatusList; + } + + /** + * Remove all reservations with incomplete state + * + * @param SalableQuantityInconsistency[] $inconsistencies + * @return SalableQuantityInconsistency[] + */ + public function execute(array $inconsistencies): array + { + return array_filter( + $inconsistencies, + function (SalableQuantityInconsistency $inconsistency) { + return !in_array($inconsistency->getOrder()->getStatus(), $this->getCompleteOrderStatusList->execute()); + } + ); + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterManagedStockProducts.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterManagedStockProducts.php new file mode 100644 index 000000000000..6a6892f4faf0 --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterManagedStockProducts.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\Framework\Exception\LocalizedException; +use Magento\InventoryApi\Model\IsProductAssignedToStockInterface; +use Magento\InventoryConfigurationApi\Api\GetStockItemConfigurationInterface; +use Magento\InventoryConfigurationApi\Exception\SkuIsNotAssignedToStockException; +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +/** + * Remove all reservations with incomplete state + */ +class FilterManagedStockProducts +{ + /** + * @var GetStockItemConfigurationInterface + */ + private $getStockItemConfiguration; + + /** + * @var IsProductAssignedToStockInterface + */ + private $isProductAssignedToStock; + + /** + * @param GetStockItemConfigurationInterface $getStockItemConfiguration + * @param IsProductAssignedToStockInterface $isProductAssignedToStock + */ + public function __construct( + GetStockItemConfigurationInterface $getStockItemConfiguration, + IsProductAssignedToStockInterface $isProductAssignedToStock + ) { + $this->getStockItemConfiguration = $getStockItemConfiguration; + $this->isProductAssignedToStock = $isProductAssignedToStock; + } + + /** + * Remove all reservations with incomplete state + * + * @param SalableQuantityInconsistency[] $inconsistencies + * @return SalableQuantityInconsistency[] + * @throws LocalizedException + * @throws SkuIsNotAssignedToStockException + */ + public function execute(array $inconsistencies): array + { + foreach ($inconsistencies as $inconsistency) { + $filteredItems = []; + foreach ($inconsistency->getItems() as $sku => $qty) { + if (false === $this->isProductAssignedToStock->execute($sku, $inconsistency->getStockId())) { + continue; + } + + $stockConfiguration = $this->getStockItemConfiguration->execute($sku, $inconsistency->getStockId()); + if ($stockConfiguration->isManageStock()) { + $filteredItems[$sku] = $qty; + } + } + $inconsistency->setItems($filteredItems); + } + + return $inconsistencies; + } +} diff --git a/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterUnresolvedReservations.php b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterUnresolvedReservations.php new file mode 100644 index 000000000000..933350b80fbb --- /dev/null +++ b/InventoryReservationCli/Model/SalableQuantityInconsistency/FilterUnresolvedReservations.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +use Magento\InventoryReservationCli\Model\SalableQuantityInconsistency; + +/** + * Remove all compensated reservations + */ +class FilterUnresolvedReservations +{ + /** + * Remove all compensated reservations + * @param SalableQuantityInconsistency[] $inconsistencies + * @return SalableQuantityInconsistency[] + */ + public function execute(array $inconsistencies): array + { + foreach ($inconsistencies as $inconsistency) { + $inconsistency->setItems(array_filter($inconsistency->getItems())); + } + + return array_filter( + $inconsistencies, + function (SalableQuantityInconsistency $inconsistency) { + return count($inconsistency->getItems()) > 0; + } + ); + } +} diff --git a/InventoryReservationCli/README.md b/InventoryReservationCli/README.md new file mode 100644 index 000000000000..b61d4ef8470c --- /dev/null +++ b/InventoryReservationCli/README.md @@ -0,0 +1,7 @@ +# InventoryReservationCli module + +The `InventoryReservationCli` module provide a cli command which helps the developer to discover inconsistencies on reservation. + +This module is part of the new inventory infrastructure. The +[Inventory Management overview](https://devdocs.magento.com/guides/v2.3/inventory/index.html) +describes the MSI (Multi-Source Inventory) project in more detail. diff --git a/InventoryReservationCli/Test/Integration/Model/GetSalableQuantityInconsistenciesTest.php b/InventoryReservationCli/Test/Integration/Model/GetSalableQuantityInconsistenciesTest.php new file mode 100644 index 000000000000..92ffefe5df8c --- /dev/null +++ b/InventoryReservationCli/Test/Integration/Model/GetSalableQuantityInconsistenciesTest.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryReservationCli\Test\Integration\Model; + +use Magento\InventoryReservationCli\Model\GetSalableQuantityInconsistencies; +use PHPUnit\Framework\TestCase; +use Magento\TestFramework\Helper\Bootstrap; + +class GetSalableQuantityInconsistenciesTest extends TestCase +{ + /** + * @var GetSalableQuantityInconsistencies + */ + private $getSalableQuantityInconsistencies; + + /** + * Initialize test dependencies + */ + protected function setUp() + { + $this->getSalableQuantityInconsistencies + = Bootstrap::getObjectManager()->get(GetSalableQuantityInconsistencies::class); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_with_reservation.php + * @throws \Magento\Framework\Validation\ValidationException + */ + public function testIncompleteOrderWithExistingReservation(): void + { + $inconsistencies = $this->getSalableQuantityInconsistencies->execute(); + self::assertSame([], $inconsistencies); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_without_reservation.php + * @throws \Magento\Framework\Validation\ValidationException + */ + public function testIncompleteOrderWithoutReservation(): void + { + $inconsistencies = $this->getSalableQuantityInconsistencies->execute(); + self::assertCount(1, $inconsistencies); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryReservationCli/Test/Integration/_fixtures/order_with_reservation.php + * @throws \Magento\Framework\Validation\ValidationException + */ + public function testCompletedOrderWithReservations(): void + { + $inconsistencies = $this->getSalableQuantityInconsistencies->execute(); + self::assertSame([], $inconsistencies); + } + + /** + * @magentoDataFixture Magento/Sales/_files/order_with_shipping_and_invoice.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryReservationCli/Test/Integration/_fixtures/broken_reservation.php + * @throws \Magento\Framework\Validation\ValidationException + */ + public function testCompletedOrderWithMissingReservations(): void + { + $inconsistencies = $this->getSalableQuantityInconsistencies->execute(); + self::assertCount(1, $inconsistencies); + } +} diff --git a/InventoryReservationCli/Test/Integration/_fixtures/broken_reservation.php b/InventoryReservationCli/Test/Integration/_fixtures/broken_reservation.php new file mode 100644 index 000000000000..87a165d2e8c5 --- /dev/null +++ b/InventoryReservationCli/Test/Integration/_fixtures/broken_reservation.php @@ -0,0 +1,26 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\TestFramework\Helper\Bootstrap; + +/** @var \Magento\Framework\ObjectManagerInterface $objectManager */ +$objectManager = Bootstrap::getObjectManager(); + +/** @var Magento\Framework\App\ResourceConnection $resourceConnection */ +$resourceConnection = $objectManager->create(Magento\Framework\App\ResourceConnection::class); + +$connection = $resourceConnection->getConnection(); +$tableName = $resourceConnection->getTableName('inventory_reservation'); + +$payload = [ + 'stock_id' => 1, + 'sku' => 'simple', + 'quantity' => -5, + 'metadata' => '{"event_type":"shipment_created","object_type":"order","object_id":"1"}' +]; + +$qry = $connection->insert($tableName, $payload); \ No newline at end of file diff --git a/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_with_reservation.php b/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_with_reservation.php new file mode 100644 index 000000000000..c2d3a7b7c819 --- /dev/null +++ b/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_with_reservation.php @@ -0,0 +1,75 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Address as OrderAddress; +use Magento\Sales\Model\Order\Item as OrderItem; +use Magento\Sales\Model\Order\Payment; +use Magento\Store\Model\StoreManagerInterface; + +require __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/default_rollback.php'; +require __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple.php'; +/** @var \Magento\Catalog\Model\Product $product */ + +$addressData = include __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/address_data.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +$billingAddress = $objectManager->create(OrderAddress::class, ['data' => $addressData]); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +/** @var Payment $payment */ +$payment = $objectManager->create(Payment::class); +$payment->setMethod('checkmo') + ->setAdditionalInformation('last_trans_id', '11122') + ->setAdditionalInformation( + 'metadata', + [ + 'type' => 'free', + 'fraudulent' => false, + ] + ); + +/** @var OrderItem $orderItem */ +$orderItem = $objectManager->create(OrderItem::class); +$orderItem->setProductId($product->getId()) + ->setQtyOrdered(2) + ->setSku($product->getSku()) + ->setBasePrice($product->getPrice()) + ->setPrice($product->getPrice()) + ->setRowTotal($product->getPrice()) + ->setProductType('simple') + ->setName($product->getName()); + +/** @var Order $order */ +$order = $objectManager->create(Order::class); +$order->setIncrementId('100000001') + ->setState(Order::STATE_PROCESSING) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) + ->setSubtotal(100) + ->setGrandTotal(100) + ->setBaseSubtotal(100) + ->setBaseGrandTotal(100) + ->setCustomerIsGuest(true) + ->setCustomerEmail('customer@null.com') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->addItem($orderItem) + ->setPayment($payment); + +/** @var \Magento\Sales\Api\OrderManagementInterface $orderManagement */ +$orderManagement = $objectManager->create(\Magento\Sales\Api\OrderManagementInterface::class); + +$orderManagement->place($order); + +/** @var \Magento\Framework\DB\Transaction $transaction */ +$transaction = $objectManager->create(\Magento\Framework\DB\Transaction::class); +$transaction->addObject($order)->save(); diff --git a/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_without_reservation.php b/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_without_reservation.php new file mode 100644 index 000000000000..08ed72584637 --- /dev/null +++ b/InventoryReservationCli/Test/Integration/_fixtures/create_incomplete_order_without_reservation.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Address as OrderAddress; +use Magento\Sales\Model\Order\Item as OrderItem; +use Magento\Sales\Model\Order\Payment; +use Magento\Store\Model\StoreManagerInterface; + +require __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/default_rollback.php'; +require __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple.php'; +/** @var \Magento\Catalog\Model\Product $product */ + +$addressData = include __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/address_data.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +$billingAddress = $objectManager->create(OrderAddress::class, ['data' => $addressData]); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +/** @var Payment $payment */ +$payment = $objectManager->create(Payment::class); +$payment->setMethod('checkmo') + ->setAdditionalInformation('last_trans_id', '11122') + ->setAdditionalInformation( + 'metadata', + [ + 'type' => 'free', + 'fraudulent' => false, + ] + ); + +/** @var OrderItem $orderItem */ +$orderItem = $objectManager->create(OrderItem::class); +$orderItem->setProductId($product->getId()) + ->setQtyOrdered(2) + ->setSku($product->getSku()) + ->setBasePrice($product->getPrice()) + ->setPrice($product->getPrice()) + ->setRowTotal($product->getPrice()) + ->setProductType('simple') + ->setName($product->getName()); + +/** @var Order $order */ +$order = $objectManager->create(Order::class); +$order->setIncrementId('100000001') + ->setState(Order::STATE_PROCESSING) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) + ->setSubtotal(100) + ->setGrandTotal(100) + ->setBaseSubtotal(100) + ->setBaseGrandTotal(100) + ->setCustomerIsGuest(true) + ->setCustomerEmail('customer@null.com') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->addItem($orderItem) + ->setPayment($payment); + +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->create(OrderRepositoryInterface::class); +$orderRepository->save($order); diff --git a/InventoryReservationCli/Test/Integration/_fixtures/order_with_reservation.php b/InventoryReservationCli/Test/Integration/_fixtures/order_with_reservation.php new file mode 100644 index 000000000000..20db3b6e83b8 --- /dev/null +++ b/InventoryReservationCli/Test/Integration/_fixtures/order_with_reservation.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Model\Order\ShipmentFactory; + +require __DIR__ . '/../../../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/order.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** @var \Magento\Sales\Model\Order $order */ +$order = $objectManager->create(\Magento\Sales\Model\Order::class) + ->loadByIncrementId('100000001'); + +/** @var \Magento\Sales\Api\OrderManagementInterface $orderManagement */ +$orderManagement = $objectManager->create(\Magento\Sales\Api\OrderManagementInterface::class); + +$orderManagement->place($order); + +/** @var \Magento\Sales\Model\Service\InvoiceService $invoiceService */ +$invoiceService = $objectManager->create(\Magento\Sales\Api\InvoiceManagementInterface::class); + +/** @var \Magento\Framework\DB\Transaction $transaction */ +$transaction = $objectManager->create(\Magento\Framework\DB\Transaction::class); + +$order->setData( + 'base_to_global_rate', + 1 +)->setData( + 'base_to_order_rate', + 1 +)->setData( + 'shipping_amount', + 20 +)->setData( + 'base_shipping_amount', + 20 +); + +$invoice = $invoiceService->prepareInvoice($order); +$invoice->register(); + +$order->setIsInProcess(true); + +$items = []; +foreach ($order->getItems() as $orderItem) { + $items[$orderItem->getId()] = $orderItem->getQtyOrdered(); +} +$shipment = $objectManager->get(ShipmentFactory::class)->create($order, $items); +$shipment->register(); + +$transaction->addObject($invoice)->addObject($shipment)->addObject($order)->save(); diff --git a/InventoryReservationCli/composer.json b/InventoryReservationCli/composer.json new file mode 100644 index 000000000000..de3446e85422 --- /dev/null +++ b/InventoryReservationCli/composer.json @@ -0,0 +1,26 @@ +{ + "name": "magento/module-inventory-reservation-cli", + "description": "N/A", + "require": { + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-sales": "*", + "magento/module-inventory-api": "*", + "magento/module-inventory-reservations-api": "*", + "magento/module-inventory-sales-api": "*", + "magento/module-inventory-configuration-api": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\InventoryReservationCli\\": "" + } + } +} diff --git a/InventoryReservationCli/etc/di.xml b/InventoryReservationCli/etc/di.xml new file mode 100644 index 000000000000..5094270705ac --- /dev/null +++ b/InventoryReservationCli/etc/di.xml @@ -0,0 +1,34 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Framework\Console\CommandListInterface"> + <arguments> + <argument name="commands" xsi:type="array"> + <item name="inventory_salable_quantity_inconsistency" xsi:type="object"> + Magento\InventoryReservationCli\Command\ShowInconsistencies + </item> + <item name="inventory_salable_quantity_compensations" xsi:type="object"> + Magento\InventoryReservationCli\Command\CreateCompensations + </item> + </argument> + </arguments> + </type> + <type name="Magento\InventoryReservationCli\Command\ShowInconsistencies"> + <arguments> + <argument name="getSalableQuantityInconsistencies" xsi:type="object"> + Magento\InventoryReservationCli\Model\GetSalableQuantityInconsistencies\Proxy + </argument> + <argument name="filterCompleteOrders" xsi:type="object"> + Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterCompleteOrders\Proxy + </argument> + <argument name="filterIncompleteOrders" xsi:type="object"> + Magento\InventoryReservationCli\Model\SalableQuantityInconsistency\FilterIncompleteOrders\Proxy + </argument> + </arguments> + </type> +</config> diff --git a/InventoryReservationCli/etc/module.xml b/InventoryReservationCli/etc/module.xml new file mode 100644 index 000000000000..a2fc04db0463 --- /dev/null +++ b/InventoryReservationCli/etc/module.xml @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_InventoryReservationCli" setup_version="1.0.0"> + <sequence> + <module name="Magento_InventoryReservations"/> + <module name="Magento_Sales"/> + </sequence> + </module> +</config> \ No newline at end of file diff --git a/InventoryReservationCli/registration.php b/InventoryReservationCli/registration.php new file mode 100644 index 000000000000..b08d2c9fb98d --- /dev/null +++ b/InventoryReservationCli/registration.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_InventoryReservationCli', + __DIR__ +); diff --git a/InventorySales/Model/IsProductSalableCondition/IsAnySourceItemInStockCondition.php b/InventorySales/Model/IsProductSalableCondition/IsAnySourceItemInStockCondition.php new file mode 100644 index 000000000000..a9952d66d38a --- /dev/null +++ b/InventorySales/Model/IsProductSalableCondition/IsAnySourceItemInStockCondition.php @@ -0,0 +1,116 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Model\IsProductSalableCondition; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface; +use Magento\InventorySalesApi\Api\IsProductSalableInterface; + +/** + * Check if product has source items with the in stock status + */ +class IsAnySourceItemInStockCondition implements IsProductSalableInterface +{ + /** + * @var SourceItemRepositoryInterface + */ + private $sourceItemRepository; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var GetSourcesAssignedToStockOrderedByPriorityInterface + */ + private $getSourcesAssignedToStockOrderedByPriority; + + /** + * @var IsSourceItemManagementAllowedForSkuInterface + */ + private $isSourceItemManagementAllowedForSku; + + /** + * @var ManageStockCondition + */ + private $manageStockCondition; + + /** + * @param SourceItemRepositoryInterface $sourceItemRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority + * @param IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku + * @param ManageStockCondition $manageStockCondition + */ + public function __construct( + SourceItemRepositoryInterface $sourceItemRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority, + IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku, + ManageStockCondition $manageStockCondition + ) { + $this->sourceItemRepository = $sourceItemRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->getSourcesAssignedToStockOrderedByPriority = $getSourcesAssignedToStockOrderedByPriority; + $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku; + $this->manageStockCondition = $manageStockCondition; + } + + /** + * @inheritdoc + */ + public function execute(string $sku, int $stockId): bool + { + // TODO Must be removed once MSI-2131 is complete. + if ($this->manageStockCondition->execute($sku, $stockId)) { + return true; + } + + if (!$this->isSourceItemManagementAllowedForSku->execute($sku)) { + return true; + } + + $sourceCodes = $this->getSourceCodesAssignedToStock($stockId); + + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, $sku) + ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCodes, 'in') + ->addFilter(SourceItemInterface::STATUS, SourceItemInterface::STATUS_IN_STOCK) + ->create(); + + $sourceItems = $this->sourceItemRepository->getList($searchCriteria)->getItems(); + + return (bool)count($sourceItems); + } + + /** + * Provides source codes for certain stock + * + * @param int $stockId + * + * @return array + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getSourceCodesAssignedToStock(int $stockId): array + { + $sources = $this->getSourcesAssignedToStockOrderedByPriority->execute($stockId); + $sourceCodes = []; + foreach ($sources as $source) { + if ($source->isEnabled()) { + $sourceCodes[] = $source->getSourceCode(); + } + } + + return $sourceCodes; + } +} diff --git a/InventorySales/Model/IsProductSalableForRequestedQtyCondition/IsAnySourceItemInStockCondition.php b/InventorySales/Model/IsProductSalableForRequestedQtyCondition/IsAnySourceItemInStockCondition.php new file mode 100644 index 000000000000..84adf3d6d19c --- /dev/null +++ b/InventorySales/Model/IsProductSalableForRequestedQtyCondition/IsAnySourceItemInStockCondition.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Model\IsProductSalableForRequestedQtyCondition; + +use Magento\InventorySales\Model\IsProductSalableCondition\IsAnySourceItemInStockCondition as IsAnySourceItemInStock; +use Magento\InventorySalesApi\Api\Data\ProductSalabilityErrorInterfaceFactory; +use Magento\InventorySalesApi\Api\Data\ProductSalableResultInterface; +use Magento\InventorySalesApi\Api\Data\ProductSalableResultInterfaceFactory; +use Magento\InventorySalesApi\Api\IsProductSalableForRequestedQtyInterface; + +/** + * @inheritdoc + */ +class IsAnySourceItemInStockCondition implements IsProductSalableForRequestedQtyInterface +{ + /** + * @var IsAnySourceItemInStock + */ + private $isAnySourceInStockCondition; + + /** + * @var ProductSalabilityErrorInterfaceFactory + */ + private $productSalabilityErrorFactory; + + /** + * @var ProductSalableResultInterfaceFactory + */ + private $productSalableResultFactory; + + /** + * @param IsAnySourceItemInStock $isAnySourceInStockCondition + * @param ProductSalabilityErrorInterfaceFactory $productSalabilityErrorFactory + * @param ProductSalableResultInterfaceFactory $productSalableResultFactory + */ + public function __construct( + IsAnySourceItemInStock $isAnySourceInStockCondition, + ProductSalabilityErrorInterfaceFactory $productSalabilityErrorFactory, + ProductSalableResultInterfaceFactory $productSalableResultFactory + ) { + $this->isAnySourceInStockCondition = $isAnySourceInStockCondition; + $this->productSalabilityErrorFactory = $productSalabilityErrorFactory; + $this->productSalableResultFactory = $productSalableResultFactory; + } + + /** + * @inheritdoc + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function execute(string $sku, int $stockId, float $requestedQty): ProductSalableResultInterface + { + $errors = []; + + if (!$this->isAnySourceInStockCondition->execute($sku, $stockId)) { + $data = [ + 'code' => 'is_any_source_item_in_stock-no_source_items_in_stock', + 'message' => __('There are no source items with the in stock status') + ]; + $errors[] = $this->productSalabilityErrorFactory->create($data); + } + + return $this->productSalableResultFactory->create(['errors' => $errors]); + } +} diff --git a/InventorySales/Model/ResourceModel/GetWebsiteIdByWebsiteCode.php b/InventorySales/Model/ResourceModel/GetWebsiteIdByWebsiteCode.php new file mode 100644 index 000000000000..b1dcd876e25d --- /dev/null +++ b/InventorySales/Model/ResourceModel/GetWebsiteIdByWebsiteCode.php @@ -0,0 +1,44 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; + +/** + * Get website id by website code + */ +class GetWebsiteIdByWebsiteCode +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * @param string $websiteCode + * @return int|null + */ + public function execute(string $websiteCode): ?int + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName('store_website'); + $selectQry = $connection->select()->from($tableName, 'website_id')->where('code = ?', $websiteCode); + + $result = $connection->fetchOne($selectQry); + return (false === $result) ? null : (int)$result; + } +} diff --git a/InventorySales/Model/ResourceModel/IsStockItemSalableCondition/BackordersCondition.php b/InventorySales/Model/ResourceModel/IsStockItemSalableCondition/BackordersCondition.php index 5e8bbf77f4d7..775a3e4e9499 100644 --- a/InventorySales/Model/ResourceModel/IsStockItemSalableCondition/BackordersCondition.php +++ b/InventorySales/Model/ResourceModel/IsStockItemSalableCondition/BackordersCondition.php @@ -9,6 +9,7 @@ use Magento\CatalogInventory\Api\StockConfigurationInterface; use Magento\Framework\DB\Select; +use Magento\InventoryConfigurationApi\Api\Data\StockItemConfigurationInterface; /** * Condition for backorders configuration. @@ -35,11 +36,16 @@ public function __construct(StockConfigurationInterface $configuration) public function execute(Select $select): string { $globalBackorders = (int)$this->configuration->getBackorders(); + $itemBackordersCondition = 'legacy_stock_item.backorders <> ' . StockItemConfigurationInterface::BACKORDERS_NO; + $useDefaultBackorders = 'legacy_stock_item.use_config_backorders'; + $itemMinQty = 'legacy_stock_item.min_qty'; - $condition = (1 === $globalBackorders) - ? 'legacy_stock_item.use_config_backorders = 1' - : 'legacy_stock_item.use_config_backorders = 0 AND legacy_stock_item.backorders = 1'; - $condition .= ' AND (legacy_stock_item.min_qty >= 0 OR legacy_stock_item.qty > legacy_stock_item.min_qty)'; + $condition = $globalBackorders === StockItemConfigurationInterface::BACKORDERS_NO + ? $useDefaultBackorders . ' = ' . StockItemConfigurationInterface::BACKORDERS_NO . ' AND ' . + $itemBackordersCondition + : $useDefaultBackorders . ' = ' . StockItemConfigurationInterface::BACKORDERS_YES_NONOTIFY . + ' OR ' . $itemBackordersCondition; + $condition .= " AND ($itemMinQty >= 0 OR legacy_stock_item.qty > $itemMinQty)"; return $condition; } diff --git a/InventorySales/Model/ReturnProcessor/DeductSourceItemQuantityOnRefund.php b/InventorySales/Model/ReturnProcessor/DeductSourceItemQuantityOnRefund.php new file mode 100644 index 000000000000..e261ed525fba --- /dev/null +++ b/InventorySales/Model/ReturnProcessor/DeductSourceItemQuantityOnRefund.php @@ -0,0 +1,122 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\InventorySales\Model\ReturnProcessor; + +use Magento\InventorySalesApi\Api\Data\ItemToSellInterfaceFactory; +use Magento\InventorySalesApi\Api\PlaceReservationsForSalesEventInterface; +use Magento\InventorySalesApi\Model\ReturnProcessor\Request\ItemsToRefundInterface; +use Magento\InventorySourceDeductionApi\Model\SourceDeductionRequestInterface; +use Magento\InventorySourceDeductionApi\Model\SourceDeductionServiceInterface; +use Magento\InventorySourceSelectionApi\Api\SourceSelectionServiceInterface; +use Magento\Sales\Api\Data\OrderInterface; + +class DeductSourceItemQuantityOnRefund +{ + /** + * @var GetSourceSelectionResultFromCreditMemoItems + */ + private $getSourceSelectionResultFromCreditMemoItems; + + /** + * @var GetSourceDeductionRequestFromSourceSelection + */ + private $getSourceDeductionRequestFromSourceSelection; + + /** + * @var SourceSelectionServiceInterface + */ + private $sourceSelectionService; + + /** + * @var SourceDeductionServiceInterface + */ + private $sourceDeductionService; + + /** + * @var ItemToSellInterfaceFactory + */ + private $itemsToSellFactory; + + /** + * @var PlaceReservationsForSalesEventInterface + */ + private $placeReservationsForSalesEvent; + + /** + * @param GetSourceSelectionResultFromCreditMemoItems $getSourceSelectionResultFromCreditMemoItems + * @param GetSourceDeductionRequestFromSourceSelection $getSourceDeductionRequestFromSourceSelection + * @param SourceSelectionServiceInterface $sourceSelectionService + * @param SourceDeductionServiceInterface $sourceDeductionService + * @param ItemToSellInterfaceFactory $itemsToSellFactory + * @param PlaceReservationsForSalesEventInterface $placeReservationsForSalesEvent + */ + public function __construct( + GetSourceSelectionResultFromCreditMemoItems $getSourceSelectionResultFromCreditMemoItems, + GetSourceDeductionRequestFromSourceSelection $getSourceDeductionRequestFromSourceSelection, + SourceSelectionServiceInterface $sourceSelectionService, + SourceDeductionServiceInterface $sourceDeductionService, + ItemToSellInterfaceFactory $itemsToSellFactory, + PlaceReservationsForSalesEventInterface $placeReservationsForSalesEvent + ) { + $this->getSourceSelectionResultFromCreditMemoItems = $getSourceSelectionResultFromCreditMemoItems; + $this->sourceSelectionService = $sourceSelectionService; + $this->sourceDeductionService = $sourceDeductionService; + $this->itemsToSellFactory = $itemsToSellFactory; + $this->placeReservationsForSalesEvent = $placeReservationsForSalesEvent; + $this->getSourceDeductionRequestFromSourceSelection = $getSourceDeductionRequestFromSourceSelection; + } + + /** + * @param OrderInterface $order + * @param ItemsToRefundInterface[] $itemsToRefund + * @param array $itemsToDeductFromSource + */ + public function execute( + OrderInterface $order, + array $itemsToRefund, + array $itemsToDeductFromSource + ): void { + $sourceSelectionResult = $this->getSourceSelectionResultFromCreditMemoItems->execute( + $order, + $itemsToRefund, + $itemsToDeductFromSource + ); + + $sourceDeductionRequests = $this->getSourceDeductionRequestFromSourceSelection->execute( + $order, + $sourceSelectionResult + ); + + foreach ($sourceDeductionRequests as $sourceDeductionRequest) { + $this->sourceDeductionService->execute($sourceDeductionRequest); + $this->placeCompensatingReservation($sourceDeductionRequest); + } + } + + /** + * Place compensating reservation after source deduction + * + * @param SourceDeductionRequestInterface $sourceDeductionRequest + */ + private function placeCompensatingReservation(SourceDeductionRequestInterface $sourceDeductionRequest): void + { + $items = []; + foreach ($sourceDeductionRequest->getItems() as $item) { + $items[] = $this->itemsToSellFactory->create([ + 'sku' => $item->getSku(), + 'qty' => $item->getQty() + ]); + } + $this->placeReservationsForSalesEvent->execute( + $items, + $sourceDeductionRequest->getSalesChannel(), + $sourceDeductionRequest->getSalesEvent() + ); + } +} diff --git a/InventorySales/Model/ReturnProcessor/GetSourceDeductionRequestFromSourceSelection.php b/InventorySales/Model/ReturnProcessor/GetSourceDeductionRequestFromSourceSelection.php new file mode 100644 index 000000000000..4d4c11147423 --- /dev/null +++ b/InventorySales/Model/ReturnProcessor/GetSourceDeductionRequestFromSourceSelection.php @@ -0,0 +1,134 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\InventorySales\Model\ReturnProcessor; + +use Magento\InventorySalesApi\Api\Data\SalesChannelInterface; +use Magento\InventorySalesApi\Api\Data\SalesChannelInterfaceFactory; +use Magento\InventorySalesApi\Api\Data\SalesEventInterface; +use Magento\InventorySalesApi\Api\Data\SalesEventInterfaceFactory; +use Magento\InventorySourceDeductionApi\Model\ItemToDeductInterface; +use Magento\InventorySourceDeductionApi\Model\ItemToDeductInterfaceFactory; +use Magento\InventorySourceDeductionApi\Model\SourceDeductionRequestInterface; +use Magento\InventorySourceDeductionApi\Model\SourceDeductionRequestInterfaceFactory; +use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionItemInterface; +use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Store\Api\WebsiteRepositoryInterface; + +class GetSourceDeductionRequestFromSourceSelection +{ + /** + * @var ItemToDeductInterfaceFactory + */ + private $itemToDeductFactory; + + /** + * @var SourceDeductionRequestInterfaceFactory + */ + private $sourceDeductionRequestFactory; + + /** + * @var SalesChannelInterfaceFactory + */ + private $salesChannelFactory; + + /** + * @var SalesEventInterfaceFactory + */ + private $salesEventFactory; + + /** + * @var WebsiteRepositoryInterface + */ + private $websiteRepository; + + /** + * @param ItemToDeductInterfaceFactory $itemToDeductFactory + * @param SourceDeductionRequestInterfaceFactory $sourceDeductionRequestFactory + * @param SalesChannelInterfaceFactory $salesChannelFactory + * @param SalesEventInterfaceFactory $salesEventFactory + * @param WebsiteRepositoryInterface $websiteRepository + */ + public function __construct( + ItemToDeductInterfaceFactory $itemToDeductFactory, + SourceDeductionRequestInterfaceFactory $sourceDeductionRequestFactory, + SalesChannelInterfaceFactory $salesChannelFactory, + SalesEventInterfaceFactory $salesEventFactory, + WebsiteRepositoryInterface $websiteRepository + ) { + $this->itemToDeductFactory = $itemToDeductFactory; + $this->sourceDeductionRequestFactory = $sourceDeductionRequestFactory; + $this->salesChannelFactory = $salesChannelFactory; + $this->salesEventFactory = $salesEventFactory; + $this->websiteRepository = $websiteRepository; + } + + /** + * @param OrderInterface $order + * @param SourceSelectionResultInterface $sourceSelectionResult + * @return array|SourceDeductionRequestInterface[] + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function execute( + OrderInterface $order, + SourceSelectionResultInterface $sourceSelectionResult + ): array { + $websiteId = (int)$order->getStore()->getWebsiteId(); + + $sourceDeductionRequests = []; + $websiteCode = $this->websiteRepository->getById($websiteId)->getCode(); + $salesChannel = $this->salesChannelFactory->create([ + 'data' => [ + 'type' => SalesChannelInterface::TYPE_WEBSITE, + 'code' => $websiteCode + ] + ]); + + $salesEvent = $this->salesEventFactory->create([ + 'type' => SalesEventInterface::EVENT_CREDITMEMO_CREATED, + 'objectType' => SalesEventInterface::OBJECT_TYPE_ORDER, + 'objectId' => (string)$order->getEntityId() + ]); + + foreach ($this->getItemsPerSource($sourceSelectionResult->getSourceSelectionItems()) as $sourceCode => $items) { + /** @var SourceDeductionRequestInterface[] $sourceDeductionRequests */ + $sourceDeductionRequests[] = $this->sourceDeductionRequestFactory->create([ + 'sourceCode' => $sourceCode, + 'items' => $items, + 'salesChannel' => $salesChannel, + 'salesEvent' => $salesEvent + ]); + } + + return $sourceDeductionRequests; + } + + /** + * @param SourceSelectionItemInterface[] $sourceSelectionItems + * @return ItemToDeductInterface[] + */ + private function getItemsPerSource(array $sourceSelectionItems): array + { + $itemsPerSource = []; + foreach ($sourceSelectionItems as $sourceSelectionItem) { + if (bccomp((string)$sourceSelectionItem->getQtyToDeduct(), '0.000001', 6) === -1) { + continue; + } + + if (!isset($itemsPerSource[$sourceSelectionItem->getSourceCode()])) { + $itemsPerSource[$sourceSelectionItem->getSourceCode()] = []; + } + $itemsPerSource[$sourceSelectionItem->getSourceCode()][] = $this->itemToDeductFactory->create([ + 'sku' => $sourceSelectionItem->getSku(), + 'qty' => $sourceSelectionItem->getQtyToDeduct(), + ]); + } + return $itemsPerSource; + } +} diff --git a/InventorySales/Model/ReturnProcessor/GetSourceSelectionResultFromCreditMemoItems.php b/InventorySales/Model/ReturnProcessor/GetSourceSelectionResultFromCreditMemoItems.php new file mode 100644 index 000000000000..f495f3b1b1bc --- /dev/null +++ b/InventorySales/Model/ReturnProcessor/GetSourceSelectionResultFromCreditMemoItems.php @@ -0,0 +1,120 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\InventorySales\Model\ReturnProcessor; + +use Magento\InventorySalesApi\Model\ReturnProcessor\GetSourceDeductedOrderItemsInterface; +use Magento\InventorySourceSelectionApi\Api\Data\ItemRequestInterfaceFactory; +use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface; +use Magento\InventorySourceSelectionApi\Api\GetDefaultSourceSelectionAlgorithmCodeInterface; +use Magento\InventorySourceSelectionApi\Api\SourceSelectionServiceInterface; +use Magento\InventorySourceSelectionApi\Model\GetInventoryRequestFromOrder; +use Magento\Sales\Api\Data\OrderInterface; + +class GetSourceSelectionResultFromCreditMemoItems +{ + /** + * @var GetSourceDeductedOrderItemsInterface + */ + private $getSourceDeductedOrderItems; + + /** + * @var ItemRequestInterfaceFactory + */ + private $itemRequestFactory; + + /** + * @var GetInventoryRequestFromOrder + */ + private $getInventoryRequestFromOrder; + + /** + * @var SourceSelectionServiceInterface + */ + private $sourceSelectionService; + + /** + * @var GetDefaultSourceSelectionAlgorithmCodeInterface + */ + private $getDefaultSourceSelectionAlgorithmCode; + + /** + * @param GetSourceDeductedOrderItemsInterface $getSourceDeductedOrderItems + * @param ItemRequestInterfaceFactory $itemRequestFactory + * @param GetInventoryRequestFromOrder $getInventoryRequestFromOrder + * @param SourceSelectionServiceInterface $sourceSelectionService + * @param GetDefaultSourceSelectionAlgorithmCodeInterface $getDefaultSourceSelectionAlgorithmCode + */ + public function __construct( + GetSourceDeductedOrderItemsInterface $getSourceDeductedOrderItems, + ItemRequestInterfaceFactory $itemRequestFactory, + GetInventoryRequestFromOrder $getInventoryRequestFromOrder, + SourceSelectionServiceInterface $sourceSelectionService, + GetDefaultSourceSelectionAlgorithmCodeInterface $getDefaultSourceSelectionAlgorithmCode + ) { + $this->getSourceDeductedOrderItems = $getSourceDeductedOrderItems; + $this->itemRequestFactory = $itemRequestFactory; + $this->getInventoryRequestFromOrder = $getInventoryRequestFromOrder; + $this->sourceSelectionService = $sourceSelectionService; + $this->getDefaultSourceSelectionAlgorithmCode = $getDefaultSourceSelectionAlgorithmCode; + } + + /** + * @param OrderInterface $order + * @param array $itemsToRefund + * @param array $itemsToDeductFromSource + * @return SourceSelectionResultInterface + */ + public function execute( + OrderInterface $order, + array $itemsToRefund, + array $itemsToDeductFromSource + ): SourceSelectionResultInterface { + $deductedItems = $this->getSourceDeductedOrderItems->execute($order, $itemsToDeductFromSource); + $requestItems = []; + foreach ($itemsToRefund as $item) { + $sku = $item->getSku(); + + $totalDeductedQty = $this->getTotalDeductedQty($item, $deductedItems); + $processedQty = $item->getProcessedQuantity() - $totalDeductedQty; + $backQty = ($processedQty > 0) ? $item->getQuantity() - $processedQty : $item->getQuantity(); + $qtyBackToSource = ($backQty > 0) ? $item->getQuantity() - $backQty : $item->getQuantity(); + + $requestItems[] = $this->itemRequestFactory->create([ + 'sku' => $sku, + 'qty' => (float)$qtyBackToSource + ]); + } + + $inventoryRequest = $this->getInventoryRequestFromOrder->execute((int)$order->getEntityId(), $requestItems); + $selectionAlgorithmCode = $this->getDefaultSourceSelectionAlgorithmCode->execute(); + + return $this->sourceSelectionService->execute($inventoryRequest, $selectionAlgorithmCode); + } + + /** + * @param $item + * @param array $deductedItems + * @return float + */ + private function getTotalDeductedQty($item, array $deductedItems): float + { + $result = 0; + + foreach ($deductedItems as $deductedItemResult) { + foreach ($deductedItemResult->getItems() as $deductedItem) { + if ($item->getSku() != $deductedItem->getSku()) { + continue; + } + $result += $deductedItem->getQuantity(); + } + } + + return $result; + } +} diff --git a/InventorySales/Observer/SalesInventory/DeductSourceItemQuantityOnRefundObserver.php b/InventorySales/Observer/SalesInventory/DeductSourceItemQuantityOnRefundObserver.php new file mode 100644 index 000000000000..f080682019b7 --- /dev/null +++ b/InventorySales/Observer/SalesInventory/DeductSourceItemQuantityOnRefundObserver.php @@ -0,0 +1,144 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Observer\SalesInventory; + +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; +use Magento\InventorySales\Model\ReturnProcessor\DeductSourceItemQuantityOnRefund; +use Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface; +use Magento\InventorySalesApi\Model\ReturnProcessor\Request\ItemsToRefundInterfaceFactory; +use Magento\Sales\Api\Data\CreditmemoItemInterface as CreditmemoItem; +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Api\OrderRepositoryInterface; + +class DeductSourceItemQuantityOnRefundObserver implements ObserverInterface +{ + /** + * @var GetSkuFromOrderItemInterface + */ + private $getSkuFromOrderItem; + + /** + * @var ItemsToRefundInterfaceFactory + */ + private $itemsToRefundFactory; + + /** + * @var IsSourceItemManagementAllowedForProductTypeInterface + */ + private $isSourceItemManagementAllowedForProductType; + + /** + * @var GetProductTypesBySkusInterface + */ + private $getProductTypesBySkus; + + /** + * @var DeductSourceItemQuantityOnRefund + */ + private $deductSourceItemQuantityOnRefund; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @param GetSkuFromOrderItemInterface $getSkuFromOrderItem + * @param ItemsToRefundInterfaceFactory $itemsToRefundFactory + * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType + * @param GetProductTypesBySkusInterface $getProductTypesBySkus + * @param DeductSourceItemQuantityOnRefund $deductSourceItemQuantityOnRefund + * @param OrderRepositoryInterface $orderRepository + */ + public function __construct( + GetSkuFromOrderItemInterface $getSkuFromOrderItem, + ItemsToRefundInterfaceFactory $itemsToRefundFactory, + IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType, + GetProductTypesBySkusInterface $getProductTypesBySkus, + DeductSourceItemQuantityOnRefund $deductSourceItemQuantityOnRefund, + OrderRepositoryInterface $orderRepository + ) { + $this->getSkuFromOrderItem = $getSkuFromOrderItem; + $this->itemsToRefundFactory = $itemsToRefundFactory; + $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType; + $this->getProductTypesBySkus = $getProductTypesBySkus; + $this->deductSourceItemQuantityOnRefund = $deductSourceItemQuantityOnRefund; + $this->orderRepository = $orderRepository; + } + + /** + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer) + { + /* @var $creditmemo \Magento\Sales\Model\Order\Creditmemo */ + $creditmemo = $observer->getEvent()->getCreditmemo(); + $order = $this->orderRepository->get($creditmemo->getOrderId()); + $itemsToRefund = $refundedOrderItemIds = []; + /** @var CreditmemoItem $item */ + foreach ($creditmemo->getItems() as $item) { + /** @var OrderItemInterface $orderItem */ + $orderItem = $item->getOrderItem(); + $sku = $this->getSkuFromOrderItem->execute($orderItem); + + if ($this->isValidItem($sku, $item)) { + $refundedOrderItemIds[] = $item->getOrderItemId(); + $qty = (float)$item->getQty(); + $processedQty = $orderItem->getQtyInvoiced() - $orderItem->getQtyRefunded() + $qty; + $itemsToRefund[$sku] = [ + 'qty' => ($itemsToRefund[$sku]['qty'] ?? 0) + $qty, + 'processedQty' => ($itemsToRefund[$sku]['processedQty'] ?? 0) + (float)$processedQty + ]; + } + } + + $itemsToDeductFromSource = []; + foreach ($itemsToRefund as $sku => $data) { + $itemsToDeductFromSource[] = $this->itemsToRefundFactory->create([ + 'sku' => $sku, + 'qty' => $data['qty'], + 'processedQty' => $data['processedQty'] + ]); + } + + if (!empty($itemsToDeductFromSource)) { + $this->deductSourceItemQuantityOnRefund->execute( + $order, + $itemsToDeductFromSource, + $refundedOrderItemIds + ); + } + } + + /** + * @param string $sku + * @param CreditmemoItem $item + * @return bool + */ + private function isValidItem(string $sku, CreditmemoItem $item): bool + { + /** @var OrderItemInterface $orderItem */ + $orderItem = $item->getOrderItem(); + // Since simple products which are the part of a grouped product are saved in the database + // (table sales_order_item) with product type grouped, we manually change the type of + // product from grouped to simple which support source management. + $typeId = $orderItem->getProductType() === 'grouped' ? 'simple' : $orderItem->getProductType(); + + $productType = $typeId ?: $this->getProductTypesBySkus->execute( + [$sku] + )[$sku]; + + return $this->isSourceItemManagementAllowedForProductType->execute($productType) + && $item->getQty() > 0 + && !$item->getBackToStock(); + } +} diff --git a/InventorySales/Test/Integration/GetStockItemData/BackorderConditionTest.php b/InventorySales/Test/Integration/GetStockItemData/BackorderConditionTest.php index a60373bf2e0b..ca2f4577dd64 100644 --- a/InventorySales/Test/Integration/GetStockItemData/BackorderConditionTest.php +++ b/InventorySales/Test/Integration/GetStockItemData/BackorderConditionTest.php @@ -16,6 +16,7 @@ use Magento\InventoryApi\Api\SourceItemsSaveInterface; use Magento\InventorySalesApi\Model\GetStockItemDataInterface; use Magento\TestFramework\Helper\Bootstrap; +use Magento\InventoryConfigurationApi\Api\Data\StockItemConfigurationInterface; use PHPUnit\Framework\TestCase; /** @@ -62,7 +63,7 @@ class BackorderConditionTest extends TestCase /** * @inheritdoc */ - protected function setUp() + protected function setUp(): void { parent::setUp(); @@ -93,7 +94,7 @@ protected function setUp() * @param int $stockId * @param array|null $expectedData */ - public function testBackordersDisabled(string $sku, int $stockId, $expectedData) + public function testBackordersDisabled(string $sku, int $stockId, $expectedData): void { $stockItemData = $this->getStockItemData->execute($sku, $stockId); @@ -110,13 +111,13 @@ public function testBackordersDisabled(string $sku, int $stockId, $expectedData) * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php * @magentoDataFixture ../../../../app/code/Magento/InventoryIndexer/Test/_files/reindex_inventory.php * @magentoConfigFixture current_store cataloginventory/item_options/backorders 1 - * @dataProvider backordersEnabledDataProvider + * @dataProvider backordersGlobalEnabledDataProvider * * @param string $sku * @param int $stockId * @param array|null $expectedData */ - public function testGlobalBackordersEnabled(string $sku, int $stockId, $expectedData) + public function testGlobalBackordersEnabled(string $sku, int $stockId, $expectedData): void { $stockItemData = $this->getStockItemData->execute($sku, $stockId); @@ -139,9 +140,9 @@ public function testGlobalBackordersEnabled(string $sku, int $stockId, $expected * @param int $stockId * @param array|null $expectedData */ - public function testStockItemBackordersDisabled(string $sku, int $stockId, $expectedData) + public function testStockItemBackordersDisabled(string $sku, int $stockId, $expectedData): void { - $this->setStockItemBackorders($sku, 0); + $this->setStockItemBackorders($sku, StockItemConfigurationInterface::BACKORDERS_NO); $stockItemData = $this->getStockItemData->execute($sku, $stockId); @@ -162,11 +163,12 @@ public function testStockItemBackordersDisabled(string $sku, int $stockId, $expe * * @param string $sku * @param int $stockId + * @param int $itemBackorders * @param array|null $expectedData */ - public function testStockItemBackordersEnabled(string $sku, int $stockId, $expectedData) + public function testStockItemBackordersEnabled(string $sku, int $stockId, int $itemBackorders, $expectedData): void { - $this->setStockItemBackorders($sku, 1); + $this->setStockItemBackorders($sku, $itemBackorders); $stockItemData = $this->getStockItemData->execute($sku, $stockId); @@ -174,11 +176,11 @@ public function testStockItemBackordersEnabled(string $sku, int $stockId, $expec } /** - * Data provider for test with enabled backorders. + * Data provider for test with global enabled backorders. * * @return array */ - public function backordersEnabledDataProvider(): array + public function backordersGlobalEnabledDataProvider(): array { return [ ['SKU-1', 10, [GetStockItemDataInterface::QUANTITY => 8.5, GetStockItemDataInterface::IS_SALABLE => 1]], @@ -187,6 +189,61 @@ public function backordersEnabledDataProvider(): array ]; } + /** + * Data provider for test with enabled backorders. + * + * @return array + */ + public function backordersEnabledDataProvider(): array + { + return [ + [ + 'SKU-1', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NONOTIFY, + [ + GetStockItemDataInterface::QUANTITY => 8.5, GetStockItemDataInterface::IS_SALABLE => 1 + ] + ], + [ + 'SKU-1', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NOTIFY, + [ + GetStockItemDataInterface::QUANTITY => 8.5, GetStockItemDataInterface::IS_SALABLE => 1 + ] + ], + [ + 'SKU-2', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NONOTIFY, + null + ], + [ + 'SKU-2', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NOTIFY, + null + ], + [ + 'SKU-3', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NONOTIFY, + [ + GetStockItemDataInterface::QUANTITY => 0, GetStockItemDataInterface::IS_SALABLE => 1 + ] + ], + [ + 'SKU-3', + 10, + StockItemConfigurationInterface::BACKORDERS_YES_NOTIFY, + [ + GetStockItemDataInterface::QUANTITY => 0, GetStockItemDataInterface::IS_SALABLE => 1 + ] + ], + ]; + } + /** * Data provider for test with disabled backorders. * @@ -217,7 +274,7 @@ private function setStockItemBackorders(string $sku, int $backordersStatus): voi /** @var StockItemInterface $legacyStockItem */ $legacyStockItem = current($stockItemsCollection->getItems()); $legacyStockItem->setBackorders($backordersStatus); - $legacyStockItem->setUseConfigBackorders(0); + $legacyStockItem->setUseConfigBackorders(false); $this->stockItemRepository->save($legacyStockItem); $sourceItem = $this->getSourceItemBySku($sku); diff --git a/InventorySales/Test/Integration/IsProductSalable/IsAnySourceItemInStockConditionTest.php b/InventorySales/Test/Integration/IsProductSalable/IsAnySourceItemInStockConditionTest.php new file mode 100644 index 000000000000..f43fdeffe702 --- /dev/null +++ b/InventorySales/Test/Integration/IsProductSalable/IsAnySourceItemInStockConditionTest.php @@ -0,0 +1,101 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Test\Integration\IsProductSalable; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\InventorySalesApi\Api\IsProductSalableInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class IsAnySourceItemInStockConditionTest extends TestCase +{ + /** + * @var IsProductSalableInterface + */ + private $isProductSalable; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var StockItemCriteriaInterfaceFactory + */ + private $stockItemCriteriaFactory; + + /** + * @var StockItemRepositoryInterface + */ + private $stockItemRepository; + + protected function setUp() + { + $objectManager = Bootstrap::getObjectManager(); + $this->isProductSalable = $objectManager->get( + IsProductSalableInterface::class + ); + $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); + $this->stockItemCriteriaFactory = $objectManager->get(StockItemCriteriaInterfaceFactory::class); + $this->stockItemRepository = $objectManager->get(StockItemRepositoryInterface::class); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + * + * @dataProvider sourceItemsStockData + * + * @magentoDbIsolation disabled + * + * @param string $sku + * @param int $stockId + * @param bool $expected + * @return void + * + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testSourceItemsAreOutOfStock(string $sku, int $stockId, bool $expected): void + { + $product = $this->productRepository->get($sku); + $stockItemSearchCriteria = $this->stockItemCriteriaFactory->create(); + $stockItemSearchCriteria->setProductsFilter($product->getId()); + $stockItemsCollection = $this->stockItemRepository->getList($stockItemSearchCriteria); + + /** @var StockItemInterface $legacyStockItem */ + $legacyStockItem = current($stockItemsCollection->getItems()); + $legacyStockItem->setBackorders(1); + $legacyStockItem->setUseConfigBackorders(0); + $this->stockItemRepository->save($legacyStockItem); + $this->assertEquals($expected, $this->isProductSalable->execute($sku, $stockId)); + } + + /** + * @return array + */ + public function sourceItemsStockData(): array + { + return [ + ['SKU-1', 10, true], + ['SKU-1', 20, false], + ['SKU-1', 30, true], + ['SKU-2', 10, false], + ['SKU-2', 20, true], + ['SKU-2', 30, true], + ['SKU-3', 10, false], + ['SKU-3', 20, false], + ['SKU-3', 30, false], + ]; + } +} diff --git a/InventorySales/Test/Integration/IsProductSalableForRequestedQty/IsAnySourceItemInStockConditionTest.php b/InventorySales/Test/Integration/IsProductSalableForRequestedQty/IsAnySourceItemInStockConditionTest.php new file mode 100644 index 000000000000..5e1729359911 --- /dev/null +++ b/InventorySales/Test/Integration/IsProductSalableForRequestedQty/IsAnySourceItemInStockConditionTest.php @@ -0,0 +1,102 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySales\Test\Integration\IsProductSalableForRequestedQty; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\InventorySalesApi\Api\IsProductSalableForRequestedQtyInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class IsAnySourceItemInStockConditionTest extends TestCase +{ + /** + * @var IsProductSalableForRequestedQtyInterface + */ + private $isProductSalable; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var StockItemCriteriaInterfaceFactory + */ + private $stockItemCriteriaFactory; + + /** + * @var StockItemRepositoryInterface + */ + private $stockItemRepository; + + protected function setUp() + { + $objectManager = Bootstrap::getObjectManager(); + $this->isProductSalable = $objectManager->get( + IsProductSalableForRequestedQtyInterface::class + ); + $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); + $this->stockItemCriteriaFactory = $objectManager->get(StockItemCriteriaInterfaceFactory::class); + $this->stockItemRepository = $objectManager->get(StockItemRepositoryInterface::class); + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/products.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + * + * @dataProvider sourceItemsStockData + * + * @magentoDbIsolation disabled + * + * @param string $sku + * @param int $stockId + * @param bool $expected + * @return void + * + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testSourceItemsAreOutOfStock(string $sku, int $stockId, bool $expected): void + { + $product = $this->productRepository->get($sku); + $stockItemSearchCriteria = $this->stockItemCriteriaFactory->create(); + $stockItemSearchCriteria->setProductsFilter($product->getId()); + $stockItemsCollection = $this->stockItemRepository->getList($stockItemSearchCriteria); + + /** @var StockItemInterface $legacyStockItem */ + $legacyStockItem = current($stockItemsCollection->getItems()); + $legacyStockItem->setBackorders(1); + $legacyStockItem->setUseConfigBackorders(0); + $this->stockItemRepository->save($legacyStockItem); + $this->assertEquals($expected, $this->isProductSalable->execute($sku, $stockId, 1)->isSalable()); + } + + /** + * @return array + */ + public function sourceItemsStockData(): array + { + return [ + ['SKU-1', 10, true], + ['SKU-1', 20, false], + ['SKU-1', 30, true], + ['SKU-2', 10, false], + ['SKU-2', 20, true], + ['SKU-2', 30, true], + ['SKU-3', 10, false], + ['SKU-3', 20, false], + ['SKU-3', 30, false], + ]; + } +} diff --git a/InventorySales/composer.json b/InventorySales/composer.json index 52f30417bb62..61ec8cb87824 100644 --- a/InventorySales/composer.json +++ b/InventorySales/composer.json @@ -13,6 +13,7 @@ "magento/module-inventory-reservations-api": "*", "magento/module-inventory-sales-api": "*", "magento/module-inventory-source-deduction-api": "*", + "magento/module-inventory-source-selection-api": "*", "magento/module-sales-inventory": "*", "magento/module-store": "*", "magento/module-sales": "*" diff --git a/InventorySales/etc/di.xml b/InventorySales/etc/di.xml index caafcc4226d3..8495bde0fbe6 100644 --- a/InventorySales/etc/di.xml +++ b/InventorySales/etc/di.xml @@ -59,6 +59,10 @@ <item name="required" xsi:type="boolean">true</item> <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableCondition\IsSetInStockStatusForCompositeProductCondition</item> </item> + <item name="is_any_source_item_in_stock" xsi:type="array"> + <item name="required" xsi:type="boolean">true</item> + <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableCondition\IsAnySourceItemInStockCondition</item> + </item> <item name="back_order" xsi:type="array"> <item name="sort_order" xsi:type="number">10</item> <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableCondition\BackOrderCondition</item> @@ -84,6 +88,10 @@ <item name="required" xsi:type="boolean">true</item> <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableForRequestedQtyCondition\IsCorrectQtyCondition</item> </item> + <item name="is_any_source_item_in_stock" xsi:type="array"> + <item name="required" xsi:type="boolean">true</item> + <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableForRequestedQtyCondition\IsAnySourceItemInStockCondition</item> + </item> <item name="back_order" xsi:type="array"> <item name="sort_order" xsi:type="number">10</item> <item name="object" xsi:type="object">Magento\InventorySales\Model\IsProductSalableForRequestedQtyCondition\BackOrderCondition</item> diff --git a/InventorySales/etc/events.xml b/InventorySales/etc/events.xml index 2c2f1eb4d092..1ed5a1f0e950 100644 --- a/InventorySales/etc/events.xml +++ b/InventorySales/etc/events.xml @@ -12,4 +12,7 @@ <event name="sales_order_item_cancel"> <observer name="inventory" instance="Magento\InventorySales\Observer\CatalogInventory\CancelOrderItemObserver"/> </event> + <event name="sales_order_creditmemo_save_after"> + <observer name="deduct_source_item_quantity_on_refund" instance="Magento\InventorySales\Observer\SalesInventory\DeductSourceItemQuantityOnRefundObserver"/> + </event> </config> diff --git a/InventorySales/i18n/en_US.csv b/InventorySales/i18n/en_US.csv index 3b270b9ff389..95c08fe2f35c 100644 --- a/InventorySales/i18n/en_US.csv +++ b/InventorySales/i18n/en_US.csv @@ -24,3 +24,4 @@ "Stock has at least one sale channel and could not be deleted.","Stock has at least one sale channel and could not be deleted." "Could not replace Sales Channels for Stock","Could not replace Sales Channels for Stock" "Wrong condition.","Wrong condition." +"There are no source items with the in stock status","There are no source items with the in stock status" diff --git a/InventoryShipping/Plugin/Sales/Model/Order/GetListShipmentRepositoryPlugin.php b/InventoryShipping/Plugin/Sales/Model/Order/GetListShipmentRepositoryPlugin.php new file mode 100644 index 000000000000..629942babf5b --- /dev/null +++ b/InventoryShipping/Plugin/Sales/Model/Order/GetListShipmentRepositoryPlugin.php @@ -0,0 +1,65 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryShipping\Plugin\Sales\Model\Order; + +use Magento\InventoryShipping\Model\ResourceModel\ShipmentSource\GetSourceCodeByShipmentId; +use Magento\Sales\Api\Data\ShipmentExtensionFactory; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Magento\Sales\Api\Data\ShipmentInterface; + +/** + * Add Source Information to shipments loaded with Magento\Sales\Api\ShipmentRepositoryInterface::getList + */ +class GetListShipmentRepositoryPlugin +{ + /** + * @var ShipmentExtensionFactory + */ + private $shipmentExtensionFactory; + + /** + * @var GetSourceCodeByShipmentId + */ + private $getSourceCodeByShipmentId; + + /** + * @param ShipmentExtensionFactory $shipmentExtensionFactory + * @param GetSourceCodeByShipmentId $getSourceCodeByShipmentId + */ + public function __construct( + ShipmentExtensionFactory $shipmentExtensionFactory, + GetSourceCodeByShipmentId $getSourceCodeByShipmentId + ) { + $this->shipmentExtensionFactory = $shipmentExtensionFactory; + $this->getSourceCodeByShipmentId = $getSourceCodeByShipmentId; + } + + /** + * Add Source Information to shipments. + * + * @param ShipmentRepositoryInterface $subject + * @param ShipmentInterface[] $searchResult + * @return ShipmentInterface[] + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetList(ShipmentRepositoryInterface $subject, $searchResult) + { + /** @var ShipmentInterface $shipment */ + foreach ($searchResult->getItems() as $shipment) { + $shipmentExtension = $shipment->getExtensionAttributes(); + if (empty($shipmentExtension)) { + $shipmentExtension = $this->shipmentExtensionFactory->create(); + } + $sourceCode = $this->getSourceCodeByShipmentId->execute((int)$shipment->getId()); + $shipmentExtension->setSourceCode($sourceCode); + $shipment->setExtensionAttributes($shipmentExtension); + } + + return $searchResult; + } +} diff --git a/InventoryShipping/etc/di.xml b/InventoryShipping/etc/di.xml index bf575600c911..569a65c032b7 100644 --- a/InventoryShipping/etc/di.xml +++ b/InventoryShipping/etc/di.xml @@ -14,6 +14,9 @@ <plugin name="LoadSourceForShipment" type="Magento\InventoryShipping\Plugin\Sales\ResourceModel\Order\Shipment\LoadSourceForShipmentPlugin"/> <plugin name="DeleteSourceForShipment" type="Magento\InventoryShipping\Plugin\Sales\ResourceModel\Order\Shipment\DeleteSourceForShipmentPlugin"/> </type> + <type name="Magento\Sales\Api\ShipmentRepositoryInterface"> + <plugin name="GetListShipmentRepository" type="Magento\InventoryShipping\Plugin\Sales\Model\Order\GetListShipmentRepositoryPlugin"/> + </type> <type name="Magento\InventoryApi\Model\SourceValidatorChain"> <arguments> <argument name="validators" xsi:type="array"> diff --git a/InventoryShippingAdminUi/Block/Adminhtml/Order/View/ShipButton.php b/InventoryShippingAdminUi/Block/Adminhtml/Order/View/ShipButton.php index a070fd5b5503..5e47d3f646f7 100644 --- a/InventoryShippingAdminUi/Block/Adminhtml/Order/View/ShipButton.php +++ b/InventoryShippingAdminUi/Block/Adminhtml/Order/View/ShipButton.php @@ -9,7 +9,9 @@ use Magento\Backend\Block\Widget\Container; use Magento\Backend\Block\Widget\Context; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Registry; +use Magento\InventoryShippingAdminUi\Model\IsOrderSourceManageable; use Magento\InventoryShippingAdminUi\Model\IsWebsiteInMultiSourceMode; /** @@ -29,21 +31,30 @@ class ShipButton extends Container */ private $isWebsiteInMultiSourceMode; + /** + * @var IsOrderSourceManageable + */ + private $isOrderSourceManageable; + /** * @param Context $context * @param Registry $registry * @param IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode * @param array $data + * @param IsOrderSourceManageable $isOrderSourceManageable */ public function __construct( Context $context, Registry $registry, IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode, - array $data = [] + array $data = [], + IsOrderSourceManageable $isOrderSourceManageable = null ) { parent::__construct($context, $data); $this->registry = $registry; $this->isWebsiteInMultiSourceMode = $isWebsiteInMultiSourceMode; + $this->isOrderSourceManageable = $isOrderSourceManageable ?? + ObjectManager::getInstance()->get(IsOrderSourceManageable::class); } /** @@ -55,7 +66,7 @@ protected function _prepareLayout() $order = $this->registry->registry('current_order'); $websiteId = (int)$order->getStore()->getWebsiteId(); - if ($this->isWebsiteInMultiSourceMode->execute($websiteId)) { + if ($this->isWebsiteInMultiSourceMode->execute($websiteId) && $this->isOrderSourceManageable->execute($order)) { $this->buttonList->update( 'order_ship', 'onclick', @@ -69,6 +80,7 @@ protected function _prepareLayout() * Source Selection URL getter * * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getSourceSelectionUrl() { diff --git a/InventoryShippingAdminUi/Model/IsOrderSourceManageable.php b/InventoryShippingAdminUi/Model/IsOrderSourceManageable.php new file mode 100644 index 000000000000..770c9bb09af0 --- /dev/null +++ b/InventoryShippingAdminUi/Model/IsOrderSourceManageable.php @@ -0,0 +1,90 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventoryShippingAdminUi\Model; + +use Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface; +use Magento\InventoryApi\Api\Data\StockInterface; +use Magento\InventoryApi\Api\StockRepositoryInterface; +use Magento\InventoryConfigurationApi\Api\GetStockItemConfigurationInterface; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; +use Magento\Sales\Api\Data\OrderInterface; + +/** + * Is source inventory of certain order is manageable + */ +class IsOrderSourceManageable +{ + /** + * @var GetSkuFromOrderItemInterface + */ + private $getSkuFromOrderItem; + + /** + * @var GetStockItemConfigurationInterface + */ + private $getStockItemConfiguration; + + /** + * @var StockRepositoryInterface + */ + private $stockRepository; + + /** + * @var IsSourceItemManagementAllowedForProductTypeInterface + */ + private $isSourceItemManagementAllowedForProductType; + + /** + * @param GetSkuFromOrderItemInterface $productRepository + * @param GetStockItemConfigurationInterface $getStockItemConfiguration + * @param StockRepositoryInterface $stockRepository + * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType + */ + public function __construct( + GetSkuFromOrderItemInterface $productRepository, + GetStockItemConfigurationInterface $getStockItemConfiguration, + StockRepositoryInterface $stockRepository, + IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType + ) { + $this->getSkuFromOrderItem = $productRepository; + $this->getStockItemConfiguration = $getStockItemConfiguration; + $this->stockRepository = $stockRepository; + $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType; + } + + /** + * Check if source manageable for certain order + * + * @param OrderInterface $order + * @return bool + */ + public function execute(OrderInterface $order): bool + { + $stocks = $this->stockRepository->getList()->getItems(); + $orderItems = $order->getItems(); + foreach ($orderItems as $orderItem) { + if (!$this->isSourceItemManagementAllowedForProductType->execute($orderItem->getProductType())) { + continue; + } + + /** @var StockInterface $stock */ + foreach ($stocks as $stock) { + $inventoryConfiguration = $this->getStockItemConfiguration->execute( + $this->getSkuFromOrderItem->execute($orderItem), + $stock->getStockId() + ); + + if ($inventoryConfiguration->isManageStock()) { + return true; + } + } + } + + return false; + } +} diff --git a/InventoryShippingAdminUi/Model/ShipmentProvider.php b/InventoryShippingAdminUi/Model/ShipmentProvider.php index ff4140923ccc..9416dcc17f91 100644 --- a/InventoryShippingAdminUi/Model/ShipmentProvider.php +++ b/InventoryShippingAdminUi/Model/ShipmentProvider.php @@ -36,7 +36,9 @@ public function getShipmentData(): array $shipmentItems = []; foreach ($items as $item) { + $orderItemId = $item['orderItemId']; if (empty($item['sources'])) { + $shipmentItems['items'][$orderItemId] = $item['qtyToShip']; continue; } $orderItemId = $item['orderItemId']; diff --git a/InventoryShippingAdminUi/Observer/NewShipmentLoadBefore.php b/InventoryShippingAdminUi/Observer/NewShipmentLoadBefore.php index cd1744bf46d5..d207627d9663 100644 --- a/InventoryShippingAdminUi/Observer/NewShipmentLoadBefore.php +++ b/InventoryShippingAdminUi/Observer/NewShipmentLoadBefore.php @@ -10,10 +10,12 @@ use Magento\Framework\App\Response\RedirectInterface; use Magento\Framework\Event\Observer as EventObserver; use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\InventoryShippingAdminUi\Model\IsWebsiteInMultiSourceMode; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\InventoryShippingAdminUi\Model\IsOrderSourceManageable; /** * Redirect to source selection page @@ -35,19 +37,28 @@ class NewShipmentLoadBefore implements ObserverInterface */ private $redirect; + /** + * @var IsOrderSourceManageable + */ + private $orderSourceManageable; + /** * @param OrderRepositoryInterface $orderRepository * @param IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode * @param RedirectInterface $redirect + * @param IsOrderSourceManageable $isOrderSourceManageable */ public function __construct( OrderRepositoryInterface $orderRepository, IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode, - RedirectInterface $redirect + RedirectInterface $redirect, + IsOrderSourceManageable $isOrderSourceManageable = null ) { $this->orderRepository = $orderRepository; $this->isWebsiteInMultiSourceMode = $isWebsiteInMultiSourceMode; $this->redirect = $redirect; + $this->orderSourceManageable = $isOrderSourceManageable ?? + ObjectManager::getInstance()->get(IsOrderSourceManageable::class); } /** @@ -67,6 +78,9 @@ public function execute(EventObserver $observer) try { $orderId = $request->getParam('order_id'); $order = $this->orderRepository->get($orderId); + if (!$this->orderSourceManageable->execute($order)) { + return; + } $websiteId = (int)$order->getStore()->getWebsiteId(); if ($this->isWebsiteInMultiSourceMode->execute($websiteId)) { $this->redirect->redirect( diff --git a/InventoryShippingAdminUi/Plugin/Sales/Block/Shipment/BackButtonUrlOnNewShipmentPagePlugin.php b/InventoryShippingAdminUi/Plugin/Sales/Block/Shipment/BackButtonUrlOnNewShipmentPagePlugin.php index 0430236832bb..2291f5a6d59d 100644 --- a/InventoryShippingAdminUi/Plugin/Sales/Block/Shipment/BackButtonUrlOnNewShipmentPagePlugin.php +++ b/InventoryShippingAdminUi/Plugin/Sales/Block/Shipment/BackButtonUrlOnNewShipmentPagePlugin.php @@ -7,9 +7,13 @@ namespace Magento\InventoryShippingAdminUi\Plugin\Sales\Block\Shipment; +use Magento\InventoryShippingAdminUi\Model\IsOrderSourceManageable; use Magento\Shipping\Block\Adminhtml\Create; use Magento\InventoryShippingAdminUi\Model\IsWebsiteInMultiSourceMode; +/** + * Modify back button URL on the shipment page in multi source mode + */ class BackButtonUrlOnNewShipmentPagePlugin { /** @@ -17,32 +21,43 @@ class BackButtonUrlOnNewShipmentPagePlugin */ private $isWebsiteInMultiSourceMode; + /** + * @var IsOrderSourceManageable + */ + private $isOrderSourceManageable; + /** * @param IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode + * @param IsOrderSourceManageable $isOrderSourceManageable */ public function __construct( - IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode + IsWebsiteInMultiSourceMode $isWebsiteInMultiSourceMode, + IsOrderSourceManageable $isOrderSourceManageable ) { $this->isWebsiteInMultiSourceMode = $isWebsiteInMultiSourceMode; + $this->isOrderSourceManageable = $isOrderSourceManageable; } /** + * Returns URL to Source Selection if source for order is manageable + * * @param Create $subject * @param $result * @return string */ public function afterGetBackUrl(Create $subject, $result) { - if (empty($subject->getShipment())) { + $shipment = $subject->getShipment(); + if (empty($shipment) || !$this->isOrderSourceManageable->execute($shipment->getOrder())) { return $result; } - $websiteId = (int)$subject->getShipment()->getOrder()->getStore()->getWebsiteId(); + $websiteId = (int)$shipment->getOrder()->getStore()->getWebsiteId(); if ($this->isWebsiteInMultiSourceMode->execute($websiteId)) { return $subject->getUrl( 'inventoryshipping/SourceSelection/index', [ - 'order_id' => $subject->getShipment() ? $subject->getShipment()->getOrderId() : null + 'order_id' => $shipment ? $shipment->getOrderId() : null ] ); } diff --git a/InventorySourceSelectionApi/Model/Algorithms/Result/GetDefaultSortedSourcesResult.php b/InventorySourceSelectionApi/Model/Algorithms/Result/GetDefaultSortedSourcesResult.php index 8d1d80a51587..3db55011d7d7 100644 --- a/InventorySourceSelectionApi/Model/Algorithms/Result/GetDefaultSortedSourcesResult.php +++ b/InventorySourceSelectionApi/Model/Algorithms/Result/GetDefaultSortedSourcesResult.php @@ -3,18 +3,19 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +/** @noinspection PhpUnusedParameterInspection */ declare(strict_types=1); namespace Magento\InventorySourceSelectionApi\Model\Algorithms\Result; +use Magento\Framework\App\ObjectManager; use Magento\InventoryApi\Api\Data\SourceInterface; -use Magento\InventoryApi\Api\Data\SourceItemInterface; -use Magento\InventoryApi\Api\SourceItemRepositoryInterface; use Magento\InventorySourceSelectionApi\Api\Data\InventoryRequestInterface; -use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface; use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionItemInterfaceFactory; +use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface; use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterfaceFactory; -use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventorySourceSelectionApi\Model\GetInStockSourceItemsBySkusAndSortedSource; +use Magento\InventorySourceSelectionApi\Model\GetSourceItemQtyAvailableInterface; /** * Return a default response for sorted source algorithms @@ -32,33 +33,39 @@ class GetDefaultSortedSourcesResult private $sourceSelectionResultFactory; /** - * @var SearchCriteriaBuilder + * @var GetInStockSourceItemsBySkusAndSortedSource */ - private $searchCriteriaBuilder; + private $getInStockSourceItemsBySkusAndSortedSource; /** - * @var SourceItemRepositoryInterface + * @var GetSourceItemQtyAvailableInterface */ - private $sourceItemRepository; + private $getSourceItemQtyAvailable; /** - * GetDefaultSortedSourcesResult constructor. - * * @param SourceSelectionItemInterfaceFactory $sourceSelectionItemFactory * @param SourceSelectionResultInterfaceFactory $sourceSelectionResultFactory - * @param SearchCriteriaBuilder $searchCriteriaBuilder - * @param SourceItemRepositoryInterface $sourceItemRepository + * @param null $searchCriteriaBuilder @deprecated + * @param null $sourceItemRepository @deprecated + * @param GetInStockSourceItemsBySkusAndSortedSource $getInStockSourceItemsBySkusAndSortedSource = null + * @param GetSourceItemQtyAvailableInterface|null $getSourceItemQtyAvailable + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( SourceSelectionItemInterfaceFactory $sourceSelectionItemFactory, SourceSelectionResultInterfaceFactory $sourceSelectionResultFactory, - SearchCriteriaBuilder $searchCriteriaBuilder, - SourceItemRepositoryInterface $sourceItemRepository + $searchCriteriaBuilder, + $sourceItemRepository, + GetInStockSourceItemsBySkusAndSortedSource $getInStockSourceItemsBySkusAndSortedSource = null, + GetSourceItemQtyAvailableInterface $getSourceItemQtyAvailable = null ) { $this->sourceSelectionItemFactory = $sourceSelectionItemFactory; $this->sourceSelectionResultFactory = $sourceSelectionResultFactory; - $this->searchCriteriaBuilder = $searchCriteriaBuilder; - $this->sourceItemRepository = $sourceItemRepository; + $this->getInStockSourceItemsBySkusAndSortedSource = $getInStockSourceItemsBySkusAndSortedSource ?: + ObjectManager::getInstance()->get(GetInStockSourceItemsBySkusAndSortedSource::class); + $this->getSourceItemQtyAvailable = $getSourceItemQtyAvailable ?? + ObjectManager::getInstance()->get(GetSourceItemQtyAvailableInterface::class); } /** @@ -73,24 +80,6 @@ private function isZero(float $floatNumber): bool return $floatNumber < 0.0000001; } - /** - * Returns source item from specific source by given SKU. Return null if source item is not found - * - * @param string $sourceCode - * @param string $sku - * @return SourceItemInterface|null - */ - private function getSourceItemBySourceCodeAndSku(string $sourceCode, string $sku): ?SourceItemInterface - { - $searchCriteria = $this->searchCriteriaBuilder - ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCode) - ->addFilter(SourceItemInterface::SKU, $sku) - ->create(); - $sourceItemsResult = $this->sourceItemRepository->getList($searchCriteria); - - return $sourceItemsResult->getTotalCount() > 0 ? current($sourceItemsResult->getItems()) : null; - } - /** * Generate default result for priority based algorithms * @@ -102,47 +91,43 @@ public function execute( InventoryRequestInterface $inventoryRequest, array $sortedSources ): SourceSelectionResultInterface { - $isShippable = true; $sourceItemSelections = []; - //TODO from performance perspective it's better to switch these foreaches and make the inner one - //TODO which loops over sources to be outermost and iterate over inventory request items inside + $itemsTdDeliver = []; foreach ($inventoryRequest->getItems() as $item) { - $itemSku = $item->getSku(); - $qtyToDeliver = $item->getQty(); - - foreach ($sortedSources as $source) { - $sourceItem = $this->getSourceItemBySourceCodeAndSku($source->getSourceCode(), $itemSku); - if (null === $sourceItem) { - continue; - } - - if ($sourceItem->getStatus() !== SourceItemInterface::STATUS_IN_STOCK) { - continue; - } - - $sourceItemQty = $sourceItem->getQuantity(); - $qtyToDeduct = min($sourceItemQty, $qtyToDeliver); - - // check if source has some qty of SKU, so it's possible to take them into account - if ($this->isZero((float)$sourceItemQty)) { - continue; - } - - $sourceItemSelections[] = $this->sourceSelectionItemFactory->create([ - 'sourceCode' => $sourceItem->getSourceCode(), - 'sku' => $itemSku, - 'qtyToDeduct' => $qtyToDeduct, - 'qtyAvailable' => $sourceItemQty - ]); - - $qtyToDeliver -= $qtyToDeduct; - } + $itemsTdDeliver[$item->getSku()] = $item->getQty(); + } + + $sortedSourceCodes = []; + foreach ($sortedSources as $sortedSource) { + $sortedSourceCodes[] = $sortedSource->getSourceCode(); + } + + $sourceItems = + $this->getInStockSourceItemsBySkusAndSortedSource->execute( + array_keys($itemsTdDeliver), + $sortedSourceCodes + ); - // if we go through all sources from the stock and there is still some qty to delivery, - // then it doesn't have enough items to delivery - if (!$this->isZero($qtyToDeliver)) { + foreach ($sourceItems as $sourceItem) { + $sourceItemQtyAvailable = $this->getSourceItemQtyAvailable->execute($sourceItem); + $qtyToDeduct = min($sourceItemQtyAvailable, $itemsTdDeliver[$sourceItem->getSku()] ?? 0.0); + + $sourceItemSelections[] = $this->sourceSelectionItemFactory->create([ + 'sourceCode' => $sourceItem->getSourceCode(), + 'sku' => $sourceItem->getSku(), + 'qtyToDeduct' => $qtyToDeduct, + 'qtyAvailable' => $sourceItemQtyAvailable + ]); + + $itemsTdDeliver[$sourceItem->getSku()] -= $qtyToDeduct; + } + + $isShippable = true; + foreach ($itemsTdDeliver as $itemToDeliver) { + if (!$this->isZero($itemToDeliver)) { $isShippable = false; + break; } } diff --git a/InventorySourceSelectionApi/Model/GetInStockSourceItemsBySkusAndSortedSource.php b/InventorySourceSelectionApi/Model/GetInStockSourceItemsBySkusAndSortedSource.php new file mode 100644 index 000000000000..4bd9a18316c7 --- /dev/null +++ b/InventorySourceSelectionApi/Model/GetInStockSourceItemsBySkusAndSortedSource.php @@ -0,0 +1,69 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySourceSelectionApi\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventoryApi\Api\SourceItemRepositoryInterface; + +/** + * Retrieve source items for a defined set of skus and sorted source codes + * + * Useful for determining presence in stock and source selection + * + * @api + */ +class GetInStockSourceItemsBySkusAndSortedSource +{ + /** + * @var SourceItemRepositoryInterface + */ + private $sourceItemRepository; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @param SourceItemRepositoryInterface $sourceItemRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @SuppressWarnings(PHPMD.LongVariable) + */ + public function __construct( + SourceItemRepositoryInterface $sourceItemRepository, + SearchCriteriaBuilder $searchCriteriaBuilder + ) { + $this->sourceItemRepository = $sourceItemRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * @param array $skus + * @param array $sortedSourceCodes + * @return SourceItemInterface[] + */ + public function execute(array $skus, array $sortedSourceCodes): array + { + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, $skus, 'in') + ->addFilter(SourceItemInterface::SOURCE_CODE, $sortedSourceCodes, 'in') + ->addFilter(SourceItemInterface::STATUS, SourceItemInterface::STATUS_IN_STOCK) + ->create(); + + $items = $this->sourceItemRepository->getList($searchCriteria)->getItems(); + + $itemsSorting = []; + foreach ($items as $item) { + $itemsSorting[] = array_search($item->getSourceCode(), $sortedSourceCodes, true); + } + + array_multisort($itemsSorting, SORT_NUMERIC, SORT_ASC, $items); + return $items; + } +} diff --git a/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableInterface.php b/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableInterface.php new file mode 100644 index 000000000000..aade3d630db0 --- /dev/null +++ b/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableInterface.php @@ -0,0 +1,24 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\InventorySourceSelectionApi\Model; + +use Magento\InventoryApi\Api\Data\SourceItemInterface; + +/** + * Get source item qty available for usage in SSA + * + * @api + */ +interface GetSourceItemQtyAvailableInterface +{ + /** + * @param SourceItemInterface $sourceItem + * + * @return float + */ + public function execute(SourceItemInterface $sourceItem): float; +} diff --git a/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableService.php b/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableService.php new file mode 100644 index 000000000000..afb02d40cd56 --- /dev/null +++ b/InventorySourceSelectionApi/Model/GetSourceItemQtyAvailableService.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySourceSelectionApi\Model; + +use Magento\InventoryApi\Api\Data\SourceItemInterface; + +/** + * Get source item qty available for usage in SSA + * Default implementation that returns source item qty without any modifications + */ +class GetSourceItemQtyAvailableService implements GetSourceItemQtyAvailableInterface +{ + /** + * @inheritDoc + */ + public function execute(SourceItemInterface $sourceItem): float + { + return $sourceItem->getQuantity(); + } +} diff --git a/InventorySourceSelectionApi/Test/Integration/GetDefaultSortedSourcesResultTest.php b/InventorySourceSelectionApi/Test/Integration/GetDefaultSortedSourcesResultTest.php new file mode 100644 index 000000000000..6532c1497a9b --- /dev/null +++ b/InventorySourceSelectionApi/Test/Integration/GetDefaultSortedSourcesResultTest.php @@ -0,0 +1,188 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySourceSelectionApi\Test\Integration; + +use Magento\InventoryApi\Api\SourceRepositoryInterface; +use Magento\InventorySourceSelectionApi\Api\Data\InventoryRequestInterfaceFactory; +use Magento\InventorySourceSelectionApi\Api\Data\ItemRequestInterfaceFactory; +use Magento\InventorySourceSelectionApi\Model\Algorithms\Result\GetDefaultSortedSourcesResult; +use PHPUnit\Framework\TestCase; +use Magento\TestFramework\Helper\Bootstrap; + +class GetDefaultSortedSourcesResultTest extends TestCase +{ + /** + * @var GetDefaultSortedSourcesResult + */ + private $subject; + + /** + * @var InventoryRequestInterfaceFactory + */ + private $inventoryRequestFactory; + + /** + * @var ItemRequestInterfaceFactory + */ + private $itemRequestFactory; + + /** + * @var SourceRepositoryInterface + */ + private $sourceRepository; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->subject = Bootstrap::getObjectManager()->get(GetDefaultSortedSourcesResult::class); + $this->inventoryRequestFactory = Bootstrap::getObjectManager()->get(InventoryRequestInterfaceFactory::class); + $this->itemRequestFactory = Bootstrap::getObjectManager()->get(ItemRequestInterfaceFactory::class); + $this->sourceRepository = Bootstrap::getObjectManager()->get(SourceRepositoryInterface::class); + } + + /** + * @return array + */ + public function shouldReturnDefaultResultsDataProvider(): array + { + return [ + [ + 10, + [ + ['sku' => 'SKU-1', 'qty' => 7], + ], + [ + 'eu-1', + 'eu-2', + 'eu-3', + ], + [ + 'eu-1/SKU-1' => ['deduct' => 5.5, 'avail' => 5.5], + 'eu-2/SKU-1' => ['deduct' => 1.5, 'avail' => 3], + ], + true + ], + [ + 10, + [ + ['sku' => 'SKU-1', 'qty' => 15], + ], + [ + 'eu-1', + 'eu-2', + 'eu-3', + ], + [ + 'eu-1/SKU-1' => ['deduct' => 5.5, 'avail' => 5.5], + 'eu-2/SKU-1' => ['deduct' => 3, 'avail' => 3], + ], + false + ], + [ + 10, + [ + ['sku' => 'SKU-1', 'qty' => 5], + ['sku' => 'SKU-2', 'qty' => 3], + ], + [ + 'eu-1', + 'eu-2', + 'eu-3', + ], + [ + 'eu-1/SKU-1' => ['deduct' => 5, 'avail' => 5.5], + 'eu-2/SKU-1' => ['deduct' => 0, 'avail' => 3], + ], + false + ], + [ + 10, + [ + ['sku' => 'SKU-1', 'qty' => 5], + ['sku' => 'SKU-2', 'qty' => 3], + ], + [ + 'eu-3', + 'eu-2', + 'eu-1', + ], + [ + 'eu-1/SKU-1' => ['deduct' => 2, 'avail' => 5.5], + 'eu-2/SKU-1' => ['deduct' => 3, 'avail' => 3], + ], + false + ], + [ + 20, + [ + ['sku' => 'SKU-2', 'qty' => 3], + ], + [ + 'us-1', + ], + [ + 'us-1/SKU-2' => ['deduct' => 3, 'avail' => 5], + ], + true + ], + ]; + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * @dataProvider shouldReturnDefaultResultsDataProvider + * @param int $stockId + * @param array $requestItemsData + * @param array $sortedSourcesCodes + * @param array $expected + * @param bool $expectIsShippable + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testShouldReturnDefaultResults( + int $stockId, + array $requestItemsData, + array $sortedSourcesCodes, + array $expected, + bool $expectIsShippable + ): void { + $requestItems = []; + foreach ($requestItemsData as $requestItemData) { + $requestItems[] = $this->itemRequestFactory->create($requestItemData); + } + + $inventoryRequest = $this->inventoryRequestFactory->create([ + 'stockId' => $stockId, + 'items' => $requestItems + ]); + + $sortedSources = []; + foreach ($sortedSourcesCodes as $sortedSourceCode) { + $sortedSources[] = $this->sourceRepository->get($sortedSourceCode); + } + + $res = $this->subject->execute( + $inventoryRequest, + $sortedSources + ); + + $sourceSelectionItems = $res->getSourceSelectionItems(); + self::assertCount(count($expected), $sourceSelectionItems); + self::assertSame($expectIsShippable, $res->isShippable()); + + foreach ($sourceSelectionItems as $selectionItem) { + $key = $selectionItem->getSourceCode() . '/' . $selectionItem->getSku(); + self::assertSame((float) $expected[$key]['deduct'], $selectionItem->getQtyToDeduct()); + self::assertSame((float) $expected[$key]['avail'], $selectionItem->getQtyAvailable()); + } + } +} diff --git a/InventorySourceSelectionApi/Test/Integration/GetSourceItemsBySkusAndSortedSourceTest.php b/InventorySourceSelectionApi/Test/Integration/GetSourceItemsBySkusAndSortedSourceTest.php new file mode 100644 index 000000000000..42a53cd61627 --- /dev/null +++ b/InventorySourceSelectionApi/Test/Integration/GetSourceItemsBySkusAndSortedSourceTest.php @@ -0,0 +1,89 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\InventorySourceSelectionApi\Test\Integration; + +use Magento\InventoryApi\Api\Data\SourceItemInterface; +use Magento\InventorySourceSelectionApi\Model\GetInStockSourceItemsBySkusAndSortedSource; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class GetSourceItemsBySkusAndSortedSourceTest extends TestCase +{ + /** + * @var GetInStockSourceItemsBySkusAndSortedSource + */ + private $subject; + + protected function setUp() + { + parent::setUp(); + + $this->subject = Bootstrap::getObjectManager()->get(GetInStockSourceItemsBySkusAndSortedSource::class); + } + + /** + * @return array + */ + public function shouldReturnSortedSourceItemsDataProvider(): array + { + return [ + [ + ['SKU-1', 'SKU-2', 'SKU-3'], + ['eu-1', 'eu-2', 'eu-3'], + [ + 'eu-1/SKU-1' => 5.5, + 'eu-2/SKU-1' => 3.0, + ] + ], + [ + ['SKU-1', 'SKU-2', 'SKU-3'], + ['eu-3', 'eu-2', 'eu-1'], + [ + 'eu-2/SKU-1' => 3.0, + 'eu-1/SKU-1' => 5.5, + ] + ], + [ + ['SKU-1', 'SKU-2', 'SKU-3', 'SKU-6'], + ['eu-3', 'eu-2', 'eu-1'], + [ + 'eu-2/SKU-1' => 3.0, + 'eu-1/SKU-1' => 5.5, + 'eu-1/SKU-6' => 0.0 + ] + ] + ]; + } + + /** + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/sources.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/source_items.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stocks.php + * @magentoDataFixture ../../../../app/code/Magento/InventoryApi/Test/_files/stock_source_links.php + * @dataProvider shouldReturnSortedSourceItemsDataProvider + * @param array $skus + * @param array $sortedSourceCodes + * @param array $expected + */ + public function testShouldReturnSortedSourceItems(array $skus, array $sortedSourceCodes, array $expected): void + { + $sourceItems = $this->subject->execute($skus, $sortedSourceCodes); + + self::assertCount(count($expected), $sourceItems); + + $keys = []; + foreach ($sourceItems as $sourceItem) { + $key = $sourceItem->getSourceCode() . '/' . $sourceItem->getSku(); + $keys[] = $key; + self::assertSame($expected[$key], $sourceItem->getQuantity()); + self::assertSame(SourceItemInterface::STATUS_IN_STOCK, $sourceItem->getStatus()); + } + + self::assertSame(array_keys($expected), $keys, 'Sources sorting is not preserved'); + } +} diff --git a/InventorySourceSelectionApi/etc/di.xml b/InventorySourceSelectionApi/etc/di.xml index 80c5ed83b490..aa914deaaf27 100644 --- a/InventorySourceSelectionApi/etc/di.xml +++ b/InventorySourceSelectionApi/etc/di.xml @@ -11,7 +11,8 @@ type="Magento\InventorySourceSelectionApi\Model\GetSourceSelectionAlgorithmList"/> <preference for="Magento\InventorySourceSelectionApi\Api\SourceSelectionServiceInterface" type="Magento\InventorySourceSelectionApi\Model\SourceSelectionService"/> - + <preference for="Magento\InventorySourceSelectionApi\Model\GetSourceItemQtyAvailableInterface" + type="Magento\InventorySourceSelectionApi\Model\GetSourceItemQtyAvailableService"/> <type name="Magento\InventorySourceSelectionApi\Model\GetSourceSelectionAlgorithmList"> <arguments> <argument name="availableAlgorithms" xsi:type="array"> @@ -23,4 +24,13 @@ </argument> </arguments> </type> + + <type name="Magento\InventorySourceSelectionApi\Model\Algorithms\Result\GetDefaultSortedSourcesResult"> + <arguments> + <!-- Argument searchCriteriaBuilder is deprecated and it should not be used anymore --> + <argument name="searchCriteriaBuilder" xsi:type="null" /> + <!-- Argument sourceItemRepository is deprecated and it should not be used anymore --> + <argument name="sourceItemRepository" xsi:type="null" /> + </arguments> + </type> </config>