[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) <wbr@odoo.com>
This commit is contained in:
committed by
William Braeckman
parent
a91523cef2
commit
fef332bec4
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="website_sale.ReorderModal" owl="1">
|
||||
<Dialog title="env._t('Re-Order')">
|
||||
<table id="o_wsale_reorder_table" class="table table-sm">
|
||||
<thead class="bg-100">
|
||||
<tr id="o_wsale_reorder_header">
|
||||
<!-- Product Image -->
|
||||
<th class="text-start">Product</th>
|
||||
<!-- Product name + description -->
|
||||
<th/>
|
||||
<!-- Product Quantity Selector -->
|
||||
<th class="text-center">Quantity</th>
|
||||
<!-- Product price (per unit) -->
|
||||
<th class="text-end">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="o_wsale_reorder_body" class="sale_tbody">
|
||||
<t t-foreach="content.products" t-as="product" t-key="product">
|
||||
<tr>
|
||||
<td t-if="product.has_image">
|
||||
<img class="product_detail_img" t-att-alt="product.name" t-attf-src="/web/image/product.product/{{product.product_id}}/image_128"/>
|
||||
</td>
|
||||
<td t-att-colspan="product.has_image ? '1' : '2'">
|
||||
<h5><t t-esc="product.name"/></h5>
|
||||
<span class="text-muted d-none d-md-inline-block" t-if="product.description_sale" t-out="product.description_sale"/>
|
||||
</td>
|
||||
<t t-if="product.add_to_cart_allowed">
|
||||
<td class="text-center">
|
||||
<div class="css_quantity input-group input-group-sm justify-content-center">
|
||||
<a href="#" class="btn btn-link d-none d-md-inline-block" aria-label="Remove one" title="Remove one" t-on-click.stop.prevent="() => this.changeProductQty(product, product.qty - 1)">
|
||||
<i class="fa fa-minus"/>
|
||||
</a>
|
||||
<input type="text" class="text-center form-control quantity" t-on-change="(ev) => this.onChangeProductQtyInput(ev, product)"
|
||||
t-att-value="product.qty"/>
|
||||
<a href="#" class="btn btn-link d-none d-md-inline-block" aria-label="Add one" title="Add one" t-on-click.stop.prevent="() => this.changeProductQty(product, product.qty + 1)">
|
||||
<i class="fa fa-plus"/>
|
||||
</a>
|
||||
</div>
|
||||
<div t-if="product.qty_warning and product.qty_warning !== ''" class="text-warning fw-bold">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span t-esc="product.qty_warning"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-esc="formatMonetary(product.combinationInfo.price, content.currency)"/>
|
||||
</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="text-center" colspan="2">
|
||||
<div class="text-warning fw-bold">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span t-esc="getWarningForProduct(product)"/>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="o_wsale_reorder_total" class="row" name="total" style="page-break-inside: avoid;">
|
||||
<div class="col-sm-7 col-md-6 ms-auto">
|
||||
<table class="table table-sm">
|
||||
<tr class="border-black o_total">
|
||||
<td>
|
||||
<strong>Total</strong>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-out="formatMonetary(total, content.currency)"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary o_wsale_reorder_confirm" t-att-disabled="!hasBuyableProducts" t-on-click="confirmReorder">
|
||||
Add To Cart
|
||||
</button>
|
||||
<button class="btn btn-secondary o_wsale_reorder_cancel" t-on-click.stop.prevent="props.close">
|
||||
Discard
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
<t t-name="website_sale.ReorderConfirmationDialog" t-inherit="web.ConfirmationDialog" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//button[1]" position="replace">
|
||||
<button class="btn btn-primary" t-on-click="_confirm">
|
||||
Yes
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//button[2]" position="replace">
|
||||
<button class="btn btn-secondary" t-on-click="_cancel">
|
||||
No
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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)'}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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']",
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -104,6 +104,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="website_sale_enabled_portal_reorder_button"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="website_sale_enabled_portal_reorder_button"/>
|
||||
<div class="text-muted">
|
||||
Allow your customer to add products from previous order in their cart.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Shop - Products</h2>
|
||||
|
||||
@@ -2124,4 +2124,15 @@
|
||||
t-attf-onclick="location.href='/shop/category/#{slug(category)}';return false;"/>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="sale_order_re_order_btn" inherit_id="sale.sale_order_portal_template" name="Sale Order Order Again">
|
||||
<xpath expr="//t[@t-set='entries']/ul/li" position="after">
|
||||
<li t-if="request.website.enabled_portal_reorder_button and sale_order.with_user(request.env.user).sudo()._is_reorder_allowed()" class="list-group-item flex-grow-1">
|
||||
<button class="btn btn-primary w-100 o_wsale_reorder_button" t-att-data-sale-order-id="sale_order.id">
|
||||
<i class="fa fa-rotate-right me-1"/>
|
||||
Order Again
|
||||
</button>
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
+26
@@ -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.')",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user