diff --git a/addons/website_sale_coupon/__init__.py b/addons/website_sale_coupon/__init__.py new file mode 100644 index 00000000000..7d34c7c054a --- /dev/null +++ b/addons/website_sale_coupon/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/website_sale_coupon/__manifest__.py b/addons/website_sale_coupon/__manifest__.py new file mode 100644 index 00000000000..bec5b9dfa79 --- /dev/null +++ b/addons/website_sale_coupon/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': "Coupons & Promotions for eCommerce", + 'summary': """Use coupon & promotion programs in your eCommerce store""", + 'description': """ +Create coupon and promotion codes to share in order to boost your sales (free products, discounts, etc.). Shoppers can use them in the eCommerce checkout. + +Coupon & promotion programs can be edited in the Catalog menu of the Website app. + """, + 'category': 'Website', + 'version': '1.0', + 'depends': ['website_sale', 'sale_coupon'], + 'data': [ + 'views/website_sale_templates.xml', + 'views/res_config_settings_views.xml', + 'views/sale_coupon_program_views.xml', + ], + 'auto_install': True, +} diff --git a/addons/website_sale_coupon/controllers/__init__.py b/addons/website_sale_coupon/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/website_sale_coupon/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/website_sale_coupon/controllers/main.py b/addons/website_sale_coupon/controllers/main.py new file mode 100644 index 00000000000..0e0c87e3d2f --- /dev/null +++ b/addons/website_sale_coupon/controllers/main.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from odoo import http +from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.http import request + + +class WebsiteSale(WebsiteSale): + + @http.route(['/shop/pricelist']) + def pricelist(self, promo, **post): + order = request.website.sale_get_order() + coupon_status = request.env['sale.coupon.apply.code'].sudo().apply_coupon(order, promo) + if coupon_status.get('not_found'): + return super(WebsiteSale, self).pricelist(promo, **post) + elif coupon_status.get('error'): + request.session['error_promo_code'] = coupon_status['error'] + return request.redirect(post.get('r', '/shop/cart')) + + @http.route(['/shop/payment'], type='http', auth="public", website=True) + def payment(self, **post): + order = request.website.sale_get_order() + order.recompute_coupon_lines() + return super(WebsiteSale, self).payment(**post) + + @http.route(['/shop/cart'], type='http', auth="public", website=True) + def cart(self, **post): + order = request.website.sale_get_order() + order.recompute_coupon_lines() + return super(WebsiteSale, self).cart(**post) + + def _update_website_sale_coupon(self, **post): + order = request.website.sale_get_order() + order.recompute_coupon_lines() + free_shipping_lines = order._get_free_shipping_lines() + currency = order.currency_id + result = {} + if free_shipping_lines: + amount_free_shipping = sum(free_shipping_lines.mapped('price_subtotal')) + result.update({ + 'new_amount_delivery': self._format_amount(0.0, currency), + 'new_amount_untaxed': self._format_amount(order.amount_untaxed, currency), + 'new_amount_tax': self._format_amount(order.amount_tax, currency), + 'new_amount_total': self._format_amount(order.amount_total, currency), + 'new_amount_order_discounted': self._format_amount(order.reward_amount - amount_free_shipping, currency) + }) + return result + + # Override + # Add in the rendering the free_shipping_line + def _get_shop_payment_values(self, order, **kwargs): + values = super(WebsiteSale, self)._get_shop_payment_values(order, **kwargs) + values['free_shipping_lines'] = order._get_free_shipping_lines() + return values diff --git a/addons/website_sale_coupon/models/__init__.py b/addons/website_sale_coupon/models/__init__.py new file mode 100644 index 00000000000..28c756b4fed --- /dev/null +++ b/addons/website_sale_coupon/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import sale_coupon_program +from . import sale_order diff --git a/addons/website_sale_coupon/models/sale_coupon_program.py b/addons/website_sale_coupon/models/sale_coupon_program.py new file mode 100644 index 00000000000..2fee40ba519 --- /dev/null +++ b/addons/website_sale_coupon/models/sale_coupon_program.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import ValidationError + + +class SaleCouponProgram(models.Model): + _name = 'sale.coupon.program' + _inherit = ['sale.coupon.program', 'website.multi.mixin'] + + @api.constrains('promo_code', 'website_id') + def _check_promo_code_constraint(self): + """ Only case where multiple same code could coexists is if they all belong to their own website. + If the program is website generic, we should ensure there is no generic and no specific (even for other website) already + If the program is website specific, we should ensure there is no existing code for this website or False + """ + for program in self.filtered(lambda p: p.promo_code): + domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)] + if program.website_id: + domain += program.website_id.website_domain() + if self.search(domain): + raise ValidationError(_('The program code must be unique by website!')) diff --git a/addons/website_sale_coupon/models/sale_order.py b/addons/website_sale_coupon/models/sale_order.py new file mode 100644 index 00000000000..f4a0a5e6620 --- /dev/null +++ b/addons/website_sale_coupon/models/sale_order.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api +from odoo.http import request + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.one + def _compute_website_order_line(self): + """ This method will merge multiple discount lines generated by a same program + into a single one (temporary line with `new()`). + This case will only occur when the program is a discount applied on multiple + products with different taxes. + In this case, each taxes will have their own discount line. This is required + to have correct amount of taxes according to the discount. + But we wan't these lines to be `visually` merged into a single one in the + e-commerce since the end user should only see one discount line. + This is only possible since we don't show taxes in cart. + eg: + line 1: 10% discount on product with tax `A` - $15 + line 2: 10% discount on product with tax `B` - $11.5 + line 3: 10% discount on product with tax `C` - $10 + would be `hidden` and `replaced` by + line 1: 10% discount - $36.5 + + Note: The line will be created without tax(es) and the amount will be computed + depending if B2B or B2C is enabled. + """ + super(SaleOrder, self)._compute_website_order_line() + for program in self._get_applied_programs_with_rewards_on_current_order(): + program_lines = self.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id) + if len(program_lines) > 1: + price_unit = sum(l.price_subtotal if self.env.user.has_group('sale.group_show_price_subtotal') else l.price_total for l in program_lines) + self.website_order_line += self.env['sale.order.line'].new({ + 'product_id': program_lines[0].product_id.id, + 'price_unit': price_unit, + 'name': program_lines[0].name, + 'product_uom_qty': 1, + 'product_uom': program_lines[0].product_uom.id, + 'order_id': self.id, + 'is_reward_line': True, + }) + self.website_order_line -= program_lines + + + def _compute_cart_info(self): + super(SaleOrder, self)._compute_cart_info() + for order in self: + reward_lines = order.website_order_line.filtered(lambda line: line.is_reward_line) + order.cart_quantity -= int(sum(reward_lines.mapped('product_uom_qty'))) + + def get_promo_code_error(self, delete=True): + error = request.session.get('error_promo_code') + if error and delete: + request.session.pop('error_promo_code') + return error + + @api.multi + def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): + res = super(SaleOrder, self)._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty, **kwargs) + self.recompute_coupon_lines() + return res + + def _get_free_shipping_lines(self): + self.ensure_one() + free_shipping_prgs_ids = self.no_code_promo_program_ids.filtered(lambda p: p.reward_type == 'free_shipping') + if not free_shipping_prgs_ids: + return self.env['sale.order.line'] + free_shipping_product_ids = free_shipping_prgs_ids.mapped('discount_line_product_id') + return self.order_line.filtered(lambda l: l.product_id in free_shipping_product_ids) diff --git a/addons/website_sale_coupon/static/description/icon.png b/addons/website_sale_coupon/static/description/icon.png new file mode 100644 index 00000000000..f88909cc0de Binary files /dev/null and b/addons/website_sale_coupon/static/description/icon.png differ diff --git a/addons/website_sale_coupon/static/description/icon.svg b/addons/website_sale_coupon/static/description/icon.svg new file mode 100644 index 00000000000..4bb830714c8 --- /dev/null +++ b/addons/website_sale_coupon/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js b/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js new file mode 100644 index 00000000000..1401015dcad --- /dev/null +++ b/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js @@ -0,0 +1,171 @@ +odoo.define('website_sale_coupon.test', function (require) { +'use strict'; + +require("website_sale.tour"); +var tour = require("web_tour.tour"); +var ajax = require('web.ajax'); + +tour.register('shop_sale_coupon', { + test: true, + url: '/shop?search=Large%20Cabinet', +}, + [ + { + content: "open customize menu", + extra_trigger: '.oe_website_sale .o_wsale_products_searchbar_form', + trigger: '#customize-menu > a', + }, + { + content: "enable 'Show # found' if needed", + trigger: "#customize-menu a:contains(Show # found)", + run: function () { + if (!$('#customize-menu a:contains(Show # found) input').prop('checked')) { + $('#customize-menu a:contains(Show # found)').click(); + } + } + }, + /* 1. Buy 1 Large Cabinet, enable coupon code & insert 10% code */ + { + content: "select Large Cabinet", + extra_trigger: '.oe_search_found', + trigger: '.oe_product_cart a:contains("Large Cabinet")', + }, + { + content: "add 2 Large Cabinet into cart", + trigger: '#product_details input[name="add_qty"]', + run: "text 2", + }, + { + content: "click on 'Add to Cart' button", + trigger: "a:contains(Add to Cart)", + }, + { + content: "open customize menu", + extra_trigger: '.oe_website_sale .oe_cart', + trigger: '#customize-menu > a', + }, + { + content: "enable 'Promo Code' if needed", + trigger: "#customize-menu a:contains(Promo Code)", + run: function () { + if (!$('#customize-menu a:contains(Promo Code) input').prop('checked')) { + $('#customize-menu a:contains(Promo Code)').click(); + } + } + }, + { + content: "click on 'I have a promo code'", + extra_trigger: '.show_coupon', + trigger: '.show_coupon', + }, + { + content: "insert promo code '10pc'", + extra_trigger: 'form[name="coupon_code"]', + trigger: 'form[name="coupon_code"] input[name="promo"]', + run: "text 10pc", + }, + { + content: "validate the coupon", + trigger: 'form[name="coupon_code"] .a-submit', + }, + { + content: "check reward product", + trigger: '.reward_product:contains("10.0% discount on total amount")', + run: function () {}, // it's a check + }, + /* 2. Add some cabinet to get a free one, play with quantity */ + { + content: "go to shop", + trigger: '.reward_product:contains("10.0% discount on total amount")', + run: function () { + ajax.jsonRpc('/web/dataset/call', 'call', { + model: 'account.tax', + method: 'create', + args: [{ + 'name':'15% tax incl ' + _.now(), + 'amount': 15, + }], + }).then(function (tax_id) { + ajax.jsonRpc('/web/dataset/call', 'call', { + model: 'product.template', + method: 'create', + args: [{ + 'name': 'Taxed Product', + 'taxes_id': [([6, false, [tax_id]])], + 'list_price': 100, + 'website_published': true, + }], + }).then(function (data) { + location.href = '/shop'; + }); + }); + }, + }, + { + content: "type Taxed Product in search", + trigger: 'form input[name="search"]', + run: "text Taxed Product", + }, + { + content: "start search", + trigger: 'form:has(input[name="search"]) .oe_search_button', + }, + { + content: "select Taxed Product", + extra_trigger: '.oe_search_found', // Wait to be on search results or it sometimes throws concurent error (sent search form + click on product on /shop) + trigger: '.oe_product_cart a:containsExact("Taxed Product")', + }, + { + content: "click on 'Add to Cart' button", + trigger: "a:contains(Add to Cart)", + }, + { + content: "check reduction amount got recomputed and merged both discount lines into one only", + extra_trigger: '.oe_currency_value:contains("-75.50"):not(#cart_total .oe_currency_value:contains("-75.50"))', + trigger: '.oe_website_sale .oe_cart', + run: function () {}, // it's a check + }, + /* 3. Add some cabinet to get a free one, play with quantity */ + { + content: "add one Large Cabinet", + trigger: '#cart_products input.js_quantity', + run: "text 3", + }, + { + content: "check reduction amount got recomputed when changing qty", + trigger: '.oe_currency_value:contains("-107.50")', + run: function () {}, // it's a check + }, + { + content: "add more Large Cabinet into cart", + trigger: '#cart_products input.js_quantity', + run: "text 4", + }, + { + content: "check free product is added", + trigger: '#wrap:has(.reward_product:contains("Free Product - Large Cabinet"))', + run: function () {}, // it's a check + }, + { + content: "remove one cabinet from cart", + trigger: '#cart_products input.js_quantity[value="4"]', + run: "text 3", + }, + { + content: "check free product is removed", + trigger: '#wrap:not(:has(.reward_product:contains("Free Product - Large Cabinet")))', + run: function () {}, // it's a check + }, + /* 4. Check /shop/payment does not break the `merged discount lines split per tax` (eg: with _compute_tax_id) */ + { + content: "go to checkout", + trigger: 'a[href="/shop/checkout?express=1"]', + }, + { + content: "check total is unchanged", + trigger: 'tr#order_total .oe_currency_value:contains("967.50")', + run: function () {}, // it's a check + }, + ] +); +}); diff --git a/addons/website_sale_coupon/tests/__init__.py b/addons/website_sale_coupon/tests/__init__.py new file mode 100644 index 00000000000..9f48e6f73be --- /dev/null +++ b/addons/website_sale_coupon/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shop_sale_coupon diff --git a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py new file mode 100644 index 00000000000..5f3a128ee2b --- /dev/null +++ b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py @@ -0,0 +1,19 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +class TestUi(odoo.tests.HttpCase): + + post_install = True + at_install = False + + def test_01_admin_shop_sale_coupon_tour(self): + # pre enable "Show # found" option to avoid race condition... + self.env.ref("website_sale.search count").write({"active": True}) + self.phantom_js( + "/", + "odoo.__DEBUG__.services['web_tour.tour'].run('shop_sale_coupon')", + "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_sale_coupon.ready", + login="admin", + ) diff --git a/addons/website_sale_coupon/views/res_config_settings_views.xml b/addons/website_sale_coupon/views/res_config_settings_views.xml new file mode 100644 index 00000000000..af2f1beb791 --- /dev/null +++ b/addons/website_sale_coupon/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + + res.config.settings.view.form.inherit.website.sale.coupon + res.config.settings + + + +
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_sale_coupon/views/sale_coupon_program_views.xml b/addons/website_sale_coupon/views/sale_coupon_program_views.xml new file mode 100644 index 00000000000..739498bfab3 --- /dev/null +++ b/addons/website_sale_coupon/views/sale_coupon_program_views.xml @@ -0,0 +1,32 @@ + + + + + + + + sale.coupon.program.common.form + sale.coupon.program + + + + + + + + + sale.coupon.program.tree + sale.coupon.program + + + + + + + + + \ No newline at end of file diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml new file mode 100644 index 00000000000..e4f260beb35 --- /dev/null +++ b/addons/website_sale_coupon/views/website_sale_templates.xml @@ -0,0 +1,88 @@ + + + + + + + + + + +