From fef332bec4447b7ce8d789af8773c56688f4958a Mon Sep 17 00:00:00 2001 From: "Alexandre de Pape (alpa)" Date: Thu, 18 Aug 2022 08:59:34 +0000 Subject: [PATCH] [IMP] website_sale{_stock,}: reorder from portal This commit adds a new feature to eCommerce: "Reorder" The system administrator may enable the feature in the website's settings. It will add a new button on a completed sales order portal page to reorder the same content. Upon clicking the button a dialog will pop up to configure the different product's quantities and display any error that would prevent a product from being added to the cart. If the user has something in his cart already a confirmation dialog will be prompted asking the user if they want to clear their cart before adding the products to the cart. TaskId-2837571 closes odoo/odoo#96040 Signed-off-by: William Braeckman (wbr) --- addons/website_sale/__manifest__.py | 2 + addons/website_sale/controllers/main.py | 50 ++++ addons/website_sale/controllers/variant.py | 2 +- .../models/res_config_settings.py | 1 + addons/website_sale/models/sale_order.py | 4 + addons/website_sale/models/sale_order_line.py | 8 + addons/website_sale/models/website.py | 1 + .../static/src/js/tours/tour_utils.js | 10 +- .../static/src/js/website_sale_reorder.js | 227 ++++++++++++++++++ .../src/xml/website_sale_reorder_modal.xml | 98 ++++++++ .../website_sale_add_to_cart_snippet_tour.js | 6 +- .../tours/website_sale_reorder_from_portal.js | 68 ++++++ addons/website_sale/tests/__init__.py | 1 + .../test_website_sale_reorder_from_portal.py | 43 ++++ .../views/res_config_settings_views.xml | 11 + addons/website_sale/views/templates.xml | 11 + addons/website_sale_stock/controllers/main.py | 7 + .../website_sale_stock/models/sale_order.py | 1 - .../website_sale_stock_reorder_from_portal.js | 26 ++ .../static/src/js/website_sale_reorder.js | 79 ++++++ addons/website_sale_stock/tests/__init__.py | 1 + ..._website_sale_stock_reorder_from_portal.py | 69 ++++++ 22 files changed, 719 insertions(+), 7 deletions(-) create mode 100644 addons/website_sale/static/src/js/website_sale_reorder.js create mode 100644 addons/website_sale/static/src/xml/website_sale_reorder_modal.xml create mode 100644 addons/website_sale/static/tests/tours/website_sale_reorder_from_portal.js create mode 100644 addons/website_sale/tests/test_website_sale_reorder_from_portal.py create mode 100644 addons/website_sale_stock/static/src/js/tours/website_sale_stock_reorder_from_portal.js create mode 100644 addons/website_sale_stock/static/src/js/website_sale_reorder.js create mode 100644 addons/website_sale_stock/tests/test_website_sale_stock_reorder_from_portal.py diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index c6a84b95274..6300e3be315 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -61,6 +61,8 @@ 'website_sale/static/src/js/website_sale_category_link.js', 'website_sale/static/src/xml/website_sale_image_viewer.xml', 'website_sale/static/src/js/components/website_sale_image_viewer.js', + 'website_sale/static/src/xml/website_sale_reorder_modal.xml', + 'website_sale/static/src/js/website_sale_reorder.js', ], 'web._assets_primary_variables': [ 'website_sale/static/src/scss/primary_variables.scss', diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index ab44e1a8771..32e1964a317 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -21,6 +21,7 @@ from odoo.exceptions import AccessError, MissingError, ValidationError from odoo.addons.portal.controllers.portal import _build_url_w_params from odoo.addons.website.controllers import main from odoo.addons.website.controllers.form import WebsiteForm +from odoo.addons.sale.controllers import portal from odoo.osv import expression from odoo.tools import lazy from odoo.tools.json import scriptsafe as json_scriptsafe @@ -836,6 +837,12 @@ class WebsiteSale(http.Controller): return request.website.sale_get_order().cart_quantity return request.session['website_sale_cart_quantity'] + @http.route(['/shop/cart/clear'], type='json', auth="public", website=True) + def clear_cart(self): + order = request.website.sale_get_order() + for line in order.order_line: + line.unlink() + # ------------------------------------------------------ # Checkout # ------------------------------------------------------ @@ -1531,3 +1538,46 @@ class PaymentPortal(payment_portal.PaymentPortal): self._validate_transaction_for_order(tx_sudo, order_id) return tx_sudo._get_processing_values() + + +class CustomerPortal(portal.CustomerPortal): + def _sale_reorder_get_line_context(self): + return {} + + @http.route('/my/orders/reorder_modal_content', type='json', auth='public', website=True) + def _get_saleorder_reorder_content_modal(self, order_id, access_token): + try: + sale_order = self._document_check_access('sale.order', order_id, access_token=access_token) + except (AccessError, MissingError): + return request.redirect('/my') + + pricelist = request.env['website'].get_current_website().get_current_pricelist() + currency = pricelist.currency_id + result = { + 'currency': { + 'symbol': currency.symbol, + 'decimal_places': currency.decimal_places, + 'position': currency.position, + }, + 'products': [], + } + for line in sale_order.order_line: + if line.display_type: + continue + res = { + 'product_template_id': line.product_id.product_tmpl_id.id, + 'product_id': line.product_id.id, + 'type': line.product_id.type, + 'name': line.product_id.name, + 'description_sale': line.product_id.description_sale, + 'qty': line.product_uom_qty, + 'add_to_cart_allowed': line.with_user(request.env.user).sudo()._is_reorder_allowed(), + 'has_image': bool(line.product_id.image_128), + } + if res['add_to_cart_allowed']: + res['combinationInfo'] = line.product_id.product_tmpl_id.with_context(**self._sale_reorder_get_line_context())\ + ._get_combination_info([], res['product_id'], res['qty'], pricelist) + else: + res['combinationInfo'] = {} + result['products'].append(res) + return result diff --git a/addons/website_sale/controllers/variant.py b/addons/website_sale/controllers/variant.py index 692b8363903..0464359b986 100644 --- a/addons/website_sale/controllers/variant.py +++ b/addons/website_sale/controllers/variant.py @@ -18,7 +18,7 @@ class WebsiteSaleVariantController(VariantController): if request.website.google_analytics_key: combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination) - if request.website.product_page_image_width != 'none': + if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False): carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={ 'product': request.env['product.template'].browse(combination['product_template_id']), 'product_variant': request.env['product.product'].browse(combination['product_id']), diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index 69c2cfc5ddf..b671117dc81 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -61,6 +61,7 @@ class ResConfigSettings(models.TransientModel): readonly=False, required=True) website_sale_prevent_zero_price_sale = fields.Boolean(string="Prevent Sale of Zero Priced Product", related='website_id.prevent_zero_price_sale', readonly=False) website_sale_contact_us_button_url = fields.Char(string="Button URL", related='website_id.contact_us_button_url', readonly=False) + website_sale_enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal", related='website_id.enabled_portal_reorder_button', readonly=False) @api.depends('website_id') def _compute_terms_url(self): diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index e2e1def1e36..daac3ed40c5 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -409,3 +409,7 @@ class SaleOrder(models.Model): if clear: self.shop_warning = '' return warn + + def _is_reorder_allowed(self): + self.ensure_one() + return self.state == 'sale' and any(line._is_reorder_allowed() for line in self.order_line) diff --git a/addons/website_sale/models/sale_order_line.py b/addons/website_sale/models/sale_order_line.py index 598b41c9170..5a94e1ed1a5 100644 --- a/addons/website_sale/models/sale_order_line.py +++ b/addons/website_sale/models/sale_order_line.py @@ -58,3 +58,11 @@ class SaleOrderLine(models.Model): def _show_in_cart(self): self.ensure_one() return True + + def _is_reorder_allowed(self): + self.ensure_one() + # TODO: when website_sale_subscription is merged, this should be overridden by both + # website_sale_subscription and website_sale_renting instead. + if 'temporal_type' in self and self.temporal_type: + return False + return self.product_id._is_add_to_cart_allowed() diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index 1e9df3ade54..cdb198735ab 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -105,6 +105,7 @@ class Website(models.Model): prevent_zero_price_sale_text = fields.Char(string="Text to show instead of price", translate=True, default="Not Available For Sale") contact_us_button_url = fields.Char(string="Contact Us Button URL", translate=True, default="/contactus") + enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal") @api.depends('all_pricelist_ids') def _compute_pricelist_ids(self): diff --git a/addons/website_sale/static/src/js/tours/tour_utils.js b/addons/website_sale/static/src/js/tours/tour_utils.js index 621f9f1de62..f7dc8b95bc7 100644 --- a/addons/website_sale/static/src/js/tours/tour_utils.js +++ b/addons/website_sale/static/src/js/tours/tour_utils.js @@ -14,10 +14,16 @@ odoo.define("website_sale.tour_utils", function (require) { }; } - function assertCartContains(productName, backend = false) { + function assertCartContains({productName, backend, notContains = false} = {}) { + let trigger = `a:contains(${productName})`; + + if (notContains) { + trigger = `:not(${trigger})`; + } return { content: `Checking if ${productName} is in the cart`, - trigger: `${backend ? "iframe" : ""} a:contains(${productName})`, + trigger: `${backend ? "iframe" : ""} ${trigger}`, + run: () => {} }; } diff --git a/addons/website_sale/static/src/js/website_sale_reorder.js b/addons/website_sale/static/src/js/website_sale_reorder.js new file mode 100644 index 00000000000..6087001f0ef --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_reorder.js @@ -0,0 +1,227 @@ +/** @odoo-module **/ + +import { debounce as debounceFn } from "@web/core/utils/timing"; +import publicWidget from "web.public.widget"; +import { localization as l10n } from "@web/core/l10n/localization"; +import { ComponentWrapper } from "web.OwlCompatibility"; +import { intersperse, nbsp } from "@web/core/utils/strings"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +/** + * Inserts "thousands" separators in the provided number. + * + * @private + * @param {string} string representing integer number + * @param {string} [thousandsSep=","] the separator to insert + * @param {number[]} [grouping=[]] + * array of relative offsets at which to insert `thousandsSep`. + * See `strings.intersperse` method. + * @returns {string} + */ +function insertThousandsSep(number, thousandsSep = ",", grouping = []) { + const negative = number[0] === "-"; + number = negative ? number.slice(1) : number; + return (negative ? "-" : "") + intersperse(number, grouping, thousandsSep); +} + +export function formatFloat(value, digits = 2) { + if (value === false) { + return ""; + } + const grouping = l10n.grouping; + const thousandsSep = l10n.thousandsSep; + const decimalPoint = l10n.decimalPoint; + let precision = digits; + const formatted = (value || 0).toFixed(precision).split("."); + formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping); + return formatted[1] ? formatted.join(decimalPoint) : formatted[0]; +} + +export function formatMonetary(value, currency) { + // Monetary fields want to display nothing when the value is unset. + // You wouldn't want a value of 0 euro if nothing has been provided. + if (value === false) { + return ""; + } + + const digits = (currency && currency.decimal_places) || 2; + + let formattedValue = formatFloat(value, digits); + + if (!currency) { + return formattedValue; + } + const formatted = [currency.symbol, formattedValue]; + if (currency.position === "after") { + formatted.reverse(); + } + return formatted.join(nbsp); +} + +// Widget responsible for openingn the modal (giving out the sale order id) + +publicWidget.registry.SaleOrderPortalReorderWidget = publicWidget.Widget.extend({ + selector: ".o_portal_sidebar", + events: { + "click .o_wsale_reorder_button": "_onReorder", + }, + + _onReorder(ev) { + const orderId = parseInt(ev.currentTarget.dataset.saleOrderId); + const urlSearchParams = new URLSearchParams(window.location.search); + if (!orderId || !urlSearchParams.has("access_token")) { + return; + } + // Open the modal + const dialogWrapper = new ComponentWrapper(this, ReorderDialogWrapper, { + orderId: orderId, + accessToken: urlSearchParams.get("access_token"), + }); + dialogWrapper.mount(document.body); + }, +}); + +import { useService } from "@web/core/utils/hooks"; +import { Dialog } from "@web/core/dialog/dialog"; + +const { Component, onRendered, onWillStart, xml } = owl; + +// Reorder Dialog + +export class ReorderDialogWrapper extends Component { + setup() { + this.dialogService = useService("dialog"); + + onRendered(() => { + this.dialogService.add(ReorderDialog, this.props); + }); + } +} +ReorderDialogWrapper.template = xml``; + +export class ReorderConfirmationDialog extends ConfirmationDialog {} +ReorderConfirmationDialog.template = "website_sale.ReorderConfirmationDialog"; + +export class ReorderDialog extends Component { + setup() { + this.rpc = useService("rpc"); + this.orm = useService("orm"); + this.dialogService = useService("dialog"); + this.formatMonetary = formatMonetary; + + onWillStart(this.onWillStartHandler.bind(this)); + } + + async onWillStartHandler() { + // Cart Qty should not change while the dialog is opened. + this.cartQty = parseInt(sessionStorage.getItem("website_sale_cart_quantity")); + if (!this.cartQty) { + this.cartQty = await this.rpc("/shop/cart/quantity"); + } + // Get required information about the order + this.content = await this.rpc("/my/orders/reorder_modal_content", { + order_id: this.props.orderId, + access_token: this.props.accessToken, + }); + // Get required information about each products + for (const product of this.content.products) { + product.debouncedLoadProductCombinationInfo = debounceFn(() => { + this.loadProductCombinationInfo(product).then(this.render.bind(this)); + }, 200); + } + } + + get total() { + return this.content.products.reduce((total, product) => { + if (product.add_to_cart_allowed) { + total += product.combinationInfo.price * product.qty; + } + return total; + }, 0); + } + + get hasBuyableProducts() { + return this.content.products.some((product) => product.add_to_cart_allowed); + } + + async loadProductCombinationInfo(product) { + product.combinationInfo = await this.rpc("/sale/get_combination_info_website", { + product_template_id: product.product_template_id, + product_id: product.product_id, + combination: [], + add_qty: product.qty, + pricelist_id: false, + context: { + website_sale_no_images: true, + }, + }); + } + + getWarningForProduct(product) { + if (!product.add_to_cart_allowed) { + return this.env._t("This product is not available for purchase."); + } + return false; + } + + changeProductQty(product, newQty) { + const productNewQty = Math.max(0, newQty); + const qtyChanged = productNewQty !== product.qty; + product.qty = productNewQty; + this.render(true); + if (!qtyChanged) { + return; + } + product.debouncedLoadProductCombinationInfo(); + } + + onChangeProductQtyInput(ev, product) { + const newQty = parseFloat(ev.target.value) || product.qty; + this.changeProductQty(product, newQty); + } + + async confirmReorder(ev) { + if (this.confirmed) { + return; + } + this.confirmed = true; + const onConfirm = async () => { + await this.addProductsToCart(); + window.location = "/shop/cart"; + }; + if (this.cartQty) { + // Open confirmation modal + this.dialogService.add(ReorderConfirmationDialog, { + body: this.env._t("Do you wish to clear your cart before adding products to it?"), + confirm: async () => { + await this.rpc("/shop/cart/clear"); + await onConfirm(); + }, + cancel: onConfirm, + }); + } else { + await onConfirm(); + } + } + + async addProductsToCart() { + for (const product of this.content.products) { + if (!product.add_to_cart_allowed) { + continue; + } + await this.rpc("/shop/cart/update_json", { + product_id: product.product_id, + add_qty: product.qty, + }); + } + } +} +ReorderDialog.props = { + close: Function, + orderId: Number, + accessToken: String, +}; +ReorderDialog.components = { + Dialog, +}; +ReorderDialog.template = "website_sale.ReorderModal"; diff --git a/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml b/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml new file mode 100644 index 00000000000..080e19535c7 --- /dev/null +++ b/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product + + QuantityPrice
+ + +
+ +
+
+ + + + + + + +
+
+ + +
+
+ + +
+ + +
+
+
+
+ + + + + +
+ Total + + +
+
+
+ + + + +
+
+ + + + + + + + + +
diff --git a/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js b/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js index 0185fe39c96..7b96b9fa457 100644 --- a/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js +++ b/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js @@ -47,8 +47,8 @@ wTourUtils.registerEditionTour('add_to_cart_snippet_tour', { wTourUtils.assertPathName('/shop/payment', 'button[name=o_payment_submit_button]'), wsTourUtils.goToCart({quantity: 4, backend: false}), - wsTourUtils.assertCartContains('Acoustic Bloc Screens', false), - wsTourUtils.assertCartContains('Conference Chair (Steel)', false), - wsTourUtils.assertCartContains('Conference Chair (Aluminium)', false), + wsTourUtils.assertCartContains({productName: 'Acoustic Bloc Screens'}), + wsTourUtils.assertCartContains({productName: 'Conference Chair (Steel)'}), + wsTourUtils.assertCartContains({productName: 'Conference Chair (Aluminium)'}), ], ); diff --git a/addons/website_sale/static/tests/tours/website_sale_reorder_from_portal.js b/addons/website_sale/static/tests/tours/website_sale_reorder_from_portal.js new file mode 100644 index 00000000000..7a9f832c6e2 --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_reorder_from_portal.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import tour from 'web_tour.tour'; +import wsTourUtils from 'website_sale.tour_utils'; +import wTourUtils from 'website.tour_utils'; + +tour.register('website_sale_reorder_from_portal', { + test: true, + url: '/my/orders', + }, + [ + // Initial reorder, nothing in cart + { + content: 'Select first order', + trigger: '.o_portal_my_doc_table a:first', + }, + wTourUtils.clickOnElement('Reorder Again', '.o_wsale_reorder_button'), + wTourUtils.clickOnElement('Confirm', '.o_wsale_reorder_confirm'), + wsTourUtils.assertCartContains({productName: 'Reorder Product 1'}), + wsTourUtils.assertCartContains({productName: 'Reorder Product 2'}), + { + content: "Check that quantity is 1", + trigger: ".js_quantity[value='1']", + }, + // Second reorder, add reorder to cart + { + content: "Go back to my orders", + trigger: "body", + run: () => { + window.location = "/my/orders"; + } + }, + { + content: 'Select first order', + trigger: '.o_portal_my_doc_table a:first', + }, + wTourUtils.clickOnElement('Reorder Again', '.o_wsale_reorder_button'), + wTourUtils.clickOnElement('Confirm', '.o_wsale_reorder_confirm'), + wTourUtils.clickOnElement('No', 'button:contains(No)'), + wsTourUtils.assertCartContains({productName: 'Reorder Product 1'}), + wsTourUtils.assertCartContains({productName: 'Reorder Product 2'}), + { + content: "Check that quantity is 2", + trigger: ".js_quantity[value='2']", + }, + // Third reorder, clear cart and reorder + { + content: "Go back to my orders", + trigger: "body", + run: () => { + window.location = "/my/orders"; + } + }, + { + content: 'Select first order', + trigger: '.o_portal_my_doc_table a:first', + }, + wTourUtils.clickOnElement('Reorder Again', '.o_wsale_reorder_button'), + wTourUtils.clickOnElement('Confirm', '.o_wsale_reorder_confirm'), + wTourUtils.clickOnElement('Yes', 'button:contains(Yes)'), + wsTourUtils.assertCartContains({productName: 'Reorder Product 1'}), + wsTourUtils.assertCartContains({productName: 'Reorder Product 2'}), + { + content: "Check that quantity is 1", + trigger: ".js_quantity[value='1']", + }, + ] +); diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py index a08a4f6615e..be7117e8f82 100644 --- a/addons/website_sale/tests/__init__.py +++ b/addons/website_sale/tests/__init__.py @@ -18,3 +18,4 @@ from . import test_website_sale_visitor from . import test_website_sale_product from . import test_website_sale_shop_variant_exclusion from . import test_website_editor +from . import test_website_sale_reorder_from_portal diff --git a/addons/website_sale/tests/test_website_sale_reorder_from_portal.py b/addons/website_sale/tests/test_website_sale_reorder_from_portal.py new file mode 100644 index 00000000000..d1b877f7a75 --- /dev/null +++ b/addons/website_sale/tests/test_website_sale_reorder_from_portal.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged('post_install', '-at_install') +class TestWebsiteSaleReorderFromPortal(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['website'].get_current_website().enabled_portal_reorder_button = True + + def test_website_sale_reorder_from_portal(self): + product_1, product_2 = self.env['product.product'].create([ + { + 'name': 'Reorder Product 1', + 'sale_ok': True, + 'website_published': True, + }, + { + 'name': 'Reorder Product 2', + 'sale_ok': True, + 'website_published': True, + }, + ]) + user_admin = self.env.ref('base.user_admin') + order = self.env['sale.order'].create({ + 'partner_id': user_admin.partner_id.id, + 'state': 'sale', + 'order_line': [ + (0, 0, { + 'product_id': product_1.id, + }), + (0, 0, { + 'product_id': product_2.id, + }), + ], + }) + order.message_subscribe(user_admin.partner_id.ids) + + self.start_tour("/", 'website_sale_reorder_from_portal', login='admin') diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index 8637560846f..502261ed49e 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -104,6 +104,17 @@ +
+
+ +
+
+
+

Shop - Products

diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index e22703b2ea5..440d67c98e1 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -2124,4 +2124,15 @@ t-attf-onclick="location.href='/shop/category/#{slug(category)}';return false;"/> + + diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py index 3e42aef9cd4..d1965dd51ee 100644 --- a/addons/website_sale_stock/controllers/main.py +++ b/addons/website_sale_stock/controllers/main.py @@ -54,3 +54,10 @@ class WebsiteSale(website_sale_controller.WebsiteSale): # We need the user mail to prefill the back of stock notification, so we put it in the value that will be sent values['user_email'] = request.env.user.email or request.session.get('stock_notification_email', '') return values + +class CustomerPortal(website_sale_controller.CustomerPortal): + def _sale_reorder_get_line_context(self): + return { + **super()._sale_reorder_get_line_context(), + 'website_sale_stock_get_quantity': True, + } diff --git a/addons/website_sale_stock/models/sale_order.py b/addons/website_sale_stock/models/sale_order.py index ddfe67e1522..92184061c0f 100644 --- a/addons/website_sale_stock/models/sale_order.py +++ b/addons/website_sale_stock/models/sale_order.py @@ -46,7 +46,6 @@ class SaleOrder(models.Model): self.shop_warning = _( "Some products became unavailable and your cart has been updated. We're sorry for the inconvenience.") return allowed_line_qty, order_line.shop_warning or self.shop_warning - return super()._verify_updated_quantity(order_line, product_id, new_qty, **kwargs) def _get_cart_and_free_qty(self, line=None, product=None, **kwargs): diff --git a/addons/website_sale_stock/static/src/js/tours/website_sale_stock_reorder_from_portal.js b/addons/website_sale_stock/static/src/js/tours/website_sale_stock_reorder_from_portal.js new file mode 100644 index 00000000000..478f1e4b21d --- /dev/null +++ b/addons/website_sale_stock/static/src/js/tours/website_sale_stock_reorder_from_portal.js @@ -0,0 +1,26 @@ +/** @odoo-module **/ + +import tour from 'web_tour.tour'; +import wTourUtils from 'website.tour_utils'; + +tour.register('website_sale_stock_reorder_from_portal', { + test: true, + url: '/my/orders', + }, + [ + { + content: 'Select first order', + trigger: '.o_portal_my_doc_table a:first', + }, + wTourUtils.clickOnElement('Reorder Again', '.o_wsale_reorder_button'), + { + content: "Check that there is one out of stock product", + trigger: "#o_wsale_reorder_body div.text-warning span:contains('This product is out of stock.')", + }, + { + content: "Check that there is one product that does not have enough stock", + trigger: "#o_wsale_reorder_body div.text-warning:contains('You ask for 2.0 Units but only 1.0 are available.')", + }, + ] +); + diff --git a/addons/website_sale_stock/static/src/js/website_sale_reorder.js b/addons/website_sale_stock/static/src/js/website_sale_reorder.js new file mode 100644 index 00000000000..8b0465069cd --- /dev/null +++ b/addons/website_sale_stock/static/src/js/website_sale_reorder.js @@ -0,0 +1,79 @@ +/** @odoo-module **/ + +import { ReorderDialog } from "@website_sale/js/website_sale_reorder"; +import { patch } from "@web/core/utils/patch"; +import { sprintf } from "@web/core/utils/strings"; + +patch(ReorderDialog.prototype, "website_sale_stock_reorder", { + /** + * @override + */ + async onWillStartHandler() { + const res = await this._super(...arguments); + for (const product of this.content.products) { + this.stockCheckCombinationInfo(product); + } + return res; + }, + + /** + * @override + */ + async loadProductCombinationInfo(product) { + await this._super(...arguments); + }, + + stockCheckCombinationInfo(product) { + // Products that should have a max quantity available should be limited by default. + if (product.combinationInfo.allow_out_of_stock_order || product.type !== "product") { + return; + } + product.max_quantity_available = product.combinationInfo.free_qty; + if (!product.max_quantity_available) { + product.add_to_cart_allowed = false; + } + if (product.max_quantity_available < product.qty) { + product.qty_warning = sprintf( + this.env._t("You ask for %s Units but only %s are available."), + product.qty.toFixed(1), + product.max_quantity_available.toFixed(1) + ); + product.qty = product.max_quantity_available; + product.stock_warning = true; + } else if (product.combinationInfo.cart_qty) { + product.qty_warning = sprintf( + this.env._t("You already have %s Units in your cart."), + product.combinationInfo.cart_qty.toFixed(1) + ); + } + }, + + /** + * @override + */ + getWarningForProduct(product) { + if (product.hasOwnProperty("max_quantity_available") && !product.max_quantity_available) { + return this.env._t("This product is out of stock."); + } + return this._super(...arguments); + }, + + /** + * @override + */ + changeProductQty(product, newQty) { + if (product.max_quantity_available && newQty > product.max_quantity_available) { + product.qty_warning = sprintf( + this.env._t("You ask for %s Units but only %s are available."), + newQty.toFixed(1), + product.max_quantity_available.toFixed(1) + ); + product.stock_warning = true; + newQty = product.max_quantity_available; + } else if (product.stock_warning) { + product.qty_warning = false; + product.stock_warning = false; + } + this._super(product, newQty); + }, +}); diff --git a/addons/website_sale_stock/tests/__init__.py b/addons/website_sale_stock/tests/__init__.py index 923b3940b8b..74f6d2d68c7 100644 --- a/addons/website_sale_stock/tests/__init__.py +++ b/addons/website_sale_stock/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_website_sale_stock_product_warehouse from . import test_website_sale_stock_stock_notification +from . import test_website_sale_stock_reorder_from_portal diff --git a/addons/website_sale_stock/tests/test_website_sale_stock_reorder_from_portal.py b/addons/website_sale_stock/tests/test_website_sale_stock_reorder_from_portal.py new file mode 100644 index 00000000000..d882fcc6e06 --- /dev/null +++ b/addons/website_sale_stock/tests/test_website_sale_stock_reorder_from_portal.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged('post_install', '-at_install') +class TestWebsiteSaleStockReorderFromPortal(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['website'].get_current_website().enabled_portal_reorder_button = True + + cls.available_product = cls.env['product.product'].create({ + 'name': 'available_product', + 'type': 'product', + 'allow_out_of_stock_order': False, + 'sale_ok': True, + 'website_published': True, + }) + cls.unavailable_product = cls.env['product.product'].create({ + 'name': 'unavailable_product', + 'type': 'product', + 'allow_out_of_stock_order': False, + 'sale_ok': True, + 'website_published': True, + }) + cls.partially_available_product = cls.env['product.product'].create({ + 'name': 'partially_available_product', + 'type': 'product', + 'allow_out_of_stock_order': False, + 'sale_ok': True, + 'website_published': True, + }) + user_admin = cls.env.ref('base.user_admin') + order = cls.env['sale.order'].create({ + 'partner_id': user_admin.partner_id.id, + 'state': 'sale', + 'order_line': [ + (0, 0, { + 'product_id': cls.available_product.id, + 'product_uom_qty': 1, + }), + (0, 0, { + 'product_id': cls.unavailable_product.id, + 'product_uom_qty': 1, + }), + (0, 0, { + 'product_id': cls.partially_available_product.id, + 'product_uom_qty': 2, + }) + ] + }) + order.message_subscribe(user_admin.partner_id.ids) + + cls.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': cls.available_product.id, + 'inventory_quantity': 10.0, + 'location_id': 8, + }).action_apply_inventory() + cls.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': cls.partially_available_product.id, + 'inventory_quantity': 1.0, + 'location_id': 8, + }).action_apply_inventory() + + def test_website_sale_stock_reorder_from_portal_stock(self): + self.start_tour("/", 'website_sale_stock_reorder_from_portal', login='admin')