[MERGE] forward port branch saas-11.2 up to cd34fda4ba3

This commit is contained in:
Christophe Simonis
2018-03-09 16:27:28 +01:00
9 changed files with 268 additions and 30 deletions
+3
View File
@@ -19,4 +19,7 @@
'report/sale_coupon_report_templates.xml',
'data/sale_coupon_email_data.xml',
],
'demo': [
'data/sale_coupon_demo.xml',
],
}
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="10_percent_auto_applied" model="sale.coupon.program">
<field name="name">Code for 10% on orders</field>
<field name="promo_code_usage">code_needed</field>
<field name="promo_code">10pc</field>
<field name="discount_apply_on">on_order</field>
<field name="discount_type">percentage</field>
<field name="discount_percentage">10.0</field>
<field name="program_type">promotion_program</field>
</record>
<record id="3_ipad_plus_1_free" model="sale.coupon.program">
<field name="name">Buy 3 ipads, get one for free</field>
<field name="promo_code_usage">no_code_needed</field>
<field name="discount_apply_on">on_order</field>
<field name="reward_type">product</field>
<field name="program_type">promotion_program</field>
<field name="reward_product_id" ref="product.product_product_6"></field>
<field name="rule_min_quantity">3</field>
<field name="rule_products_domain">[["name","ilike","ipad mini"]]</field>
</record>
</data>
</odoo>
+1 -4
View File
@@ -53,15 +53,12 @@ class SaleCoupon(models.Model):
def _check_coupon_code(self, order):
message = {}
applicable_programs = order._get_applicable_programs()
amount_total = order.amount_untaxed + order.reward_amount
if self.program_id.rule_minimum_amount_tax_inclusion == 'tax_included':
amount_total += order.amount_tax
if self.state in ('used', 'expired') or \
(self.expiration_date and self.expiration_date < order.date_order):
message = {'error': _('This coupon %s has been used or is expired.') % (self.code)}
elif self.state == 'reserved':
message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)}
elif self.program_id._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total:
elif not self.program_id._filter_on_mimimum_amount(order):
message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.program_id.rule_minimum_amount, self.program_id.currency_id.name)}
elif not self.program_id.active:
message = {'error': _('The coupon program for %s is in draft or closed state') % (self.code)}
+14 -10
View File
@@ -10,7 +10,10 @@ class SaleCouponProgram(models.Model):
_name = 'sale.coupon.program'
_description = "Sales Coupon Program"
_inherits = {'sale.coupon.rule': 'rule_id', 'sale.coupon.reward': 'reward_id'}
_order = "sequence"
# We should apply 'discount' promotion first to avoid offering free product when we should not.
# Eg: If the discount lower the SO total below the required threshold
# Note: This is only revelant when programs have the same sequence (which they have by default)
_order = "sequence, reward_type"
name = fields.Char(required=True, translate=True)
active = fields.Boolean('Active', default=True, help="A program is available for the customers when active")
@@ -136,12 +139,9 @@ class SaleCouponProgram(models.Model):
def _check_promo_code(self, order, coupon_code):
message = {}
applicable_programs = order._get_applicable_programs()
amount_total = order.amount_untaxed + order.reward_amount
if self.rule_minimum_amount_tax_inclusion == 'tax_included':
amount_total += order.amount_tax
if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number:
message = {'error': _('Promo code %s has been expired.') % (coupon_code)}
elif self._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total:
elif not self._filter_on_mimimum_amount(order):
message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.rule_minimum_amount, self.currency_id.name)}
elif self.promo_code and self.promo_code == order.promo_code:
message = {'error': _('The promo code is already applied on this order')}
@@ -151,7 +151,7 @@ class SaleCouponProgram(models.Model):
message = {'error': _('Promo code is invalid')}
elif self.rule_date_from and self.rule_date_from > order.date_order or self.rule_date_to and order.date_order > self.rule_date_to:
message = {'error': _('Promo code is expired')}
elif order.promo_code:
elif order.promo_code and self.promo_code_usage == 'code_needed':
message = {'error': _('Promotionals codes are not cumulative.')}
elif self._is_global_discount_program() and order.applied_coupon_ids.mapped('program_id').filtered(lambda program: program._is_global_discount_program()):
message = {'error': _('Global discounts are not cumulative.')}
@@ -172,12 +172,16 @@ class SaleCouponProgram(models.Model):
@api.model
def _filter_on_mimimum_amount(self, order):
# To get actual total amount of SO without any reward or discount
discounted_amount = sum(order.order_line.filtered(lambda line: line.is_reward_line).mapped('price_total'))
untaxed_amount = order.amount_untaxed - discounted_amount
untaxed_amount = order.amount_untaxed
tax_amount = order.amount_tax
# Some lines should not be considered when checking if threshold is met like delivery
untaxed_amount -= sum([line.price_subtotal for line in order._get_no_effect_on_threshold_lines()])
tax_amount -= sum([line.price_tax for line in order._get_no_effect_on_threshold_lines()])
return self.filtered(lambda program:
program.rule_minimum_amount_tax_inclusion == 'tax_included' and
program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + order.amount_tax or
program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + tax_amount or
program.rule_minimum_amount_tax_inclusion == 'tax_excluded' and
program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount)
+23 -13
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import api, fields, models, _
class SaleOrder(models.Model):
@@ -19,7 +19,15 @@ class SaleOrder(models.Model):
@api.depends('order_line')
def _compute_reward_total(self):
for order in self:
order.reward_amount = sum([line.price_subtotal for line in order.order_line.filtered(lambda line: line.is_reward_line)])
order.reward_amount = sum([line.price_subtotal for line in order._get_reward_lines()])
def _get_no_effect_on_threshold_lines(self):
self.ensure_one()
lines = self.env['sale.order.line']
# Do not count already applied promo_code discount; Do not substract itself
if self.code_promo_program_id and self.code_promo_program_id.reward_type == 'discount':
lines = self.order_line.filtered(lambda l: l.product_id == self.code_promo_program_id.discount_line_product_id)
return lines
@api.multi
def recompute_coupon_lines(self):
@@ -32,7 +40,7 @@ class SaleOrder(models.Model):
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
order = super(SaleOrder, self).copy(dict(default or {}, order_line=False))
for line in self.order_line.filtered(lambda line: not line.is_reward_line):
for line in self._get_reward_lines():
line.copy({'order_id': order.id})
order.with_context(sale_coupon_no_loop=False)._create_new_no_code_promo_reward_lines()
return order
@@ -73,7 +81,7 @@ class SaleOrder(models.Model):
if program._is_valid_product(program.reward_product_id):
reward_product_qty = max_product_qty // (program.rule_min_quantity + program.reward_product_quantity)
else:
reward_product_qty = max_product_qty
reward_product_qty = min(max_product_qty, self.order_line.filtered(lambda x: x.product_id == program.reward_product_id).product_uom_qty)
reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty)
# Take the default taxes on the reward product, mapped with the fiscal position
@@ -85,14 +93,16 @@ class SaleOrder(models.Model):
'price_unit': - price_unit,
'product_uom_qty': reward_qty,
'is_reward_line': True,
'name': "Free Product - " + program.reward_product_id.name,
'name': _("Free Product") + " - " + program.reward_product_id.name,
'product_uom': program.reward_product_id.uom_id.id,
'tax_id': [(4, tax.id, False) for tax in taxes],
}
def _get_order_lines_untaxed_amount(self):
""" Returns the untaxed sale order total amount without the rewards amount"""
return sum([x.price_subtotal for x in self.order_line.filtered(lambda x: not x.is_reward_line)])
def _get_order_lines_tax_included_amount(self):
""" Returns the taxes included sale order total amount without the rewards amount"""
free_reward_product = self.env['sale.coupon.program'].search([('reward_type', '=', 'product')]).mapped('discount_line_product_id')
return sum([x.price_total for x in self.order_line.filtered(lambda x: not x.is_reward_line or x.product_id in free_reward_product)])
def _get_reward_values_discount_fixed_amount(self, program):
total_amount = sum(self.order_line.filtered(lambda line: not line.is_reward_line).mapped('price_total'))
@@ -107,15 +117,15 @@ class SaleOrder(models.Model):
def _get_reward_values_discount_percentage(self, program):
discount_amount = 0
amount_untaxed = self._get_order_lines_untaxed_amount()
amount_total = self._get_order_lines_tax_included_amount()
max_amount = program._compute_program_amount('discount_max_amount', self.currency_id)
if program.discount_apply_on == 'on_order':
discount_amount = amount_untaxed * (program.discount_percentage / 100)
discount_amount = amount_total * (program.discount_percentage / 100)
if program.discount_apply_on == 'cheapest_product':
unit_prices = self._get_lines_unit_prices()
discount_amount = (min(unit_prices) * (program.discount_percentage) / 100)
if program.discount_apply_on == 'specific_product':
discount_amount = sum([(x.price_unit * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered(
discount_amount = sum([(x.price_total * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered(
lambda x: x.product_id == program.discount_specific_product_id)])
if program.discount_max_amount and discount_amount > max_amount:
discount_amount = max_amount
@@ -127,7 +137,7 @@ class SaleOrder(models.Model):
elif program.discount_type == 'fixed_amount':
discount_amount = self._get_reward_values_discount_fixed_amount(program)
return {
'name': "Discount: %s" % (program.name),
'name': _("Discount: ") + program.name,
'product_id': program.discount_line_product_id.id,
'price_unit': - discount_amount,
'product_uom_qty': 1.0,
@@ -181,7 +191,7 @@ class SaleOrder(models.Model):
('promo_code_usage', '=', 'no_code_needed'),
# ('promo_applicability', '=', 'on_current_order')
])._filter_programs_from_common_rules(self)
return programs.sorted('sequence')
return programs
def _create_new_no_code_promo_reward_lines(self):
'''Apply new programs that are applicable'''
+1
View File
@@ -5,3 +5,4 @@ from . import test_program_without_code_operations
from . import test_program_with_code_operations
from . import test_program_rules
from . import test_program_rewards
from . import test_program_numbers
+7
View File
@@ -57,3 +57,10 @@ class TestSaleCouponCommon(common.TransactionCase):
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id),
'active': True,
})
self.tax_10pc_incl = self.env['account.tax'].create({
'name': "10% Tax incl",
'amount_type': 'percent',
'amount': 10,
'price_include': True,
})
+180
View File
@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon
class TestSaleCouponProgramNumbers(TestSaleCouponCommon):
def setUp(self):
super(TestSaleCouponProgramNumbers, self).setUp()
self.iPadMini = self.env.ref('product.product_product_6').copy()
self.computerCase = self.env.ref('product.product_product_16').copy()
self.littleServer = self.env.ref('product.consu_delivery_02').copy()
self.steve = self.env['res.partner'].create({
'name': 'Steve Bucknor',
'customer': True,
'email': 'steve.bucknor@example.com',
})
self.empty_order = self.env['sale.order'].create({
'partner_id': self.steve.id
})
self.p1 = self.env['sale.coupon.program'].create({
'name': 'Code for 10% on orders',
'promo_code_usage': 'code_needed',
'promo_code': 'test_10pc',
'discount_type': 'percentage',
'discount_percentage': 10.0,
'program_type': 'promotion_program',
})
self.p2 = self.env['sale.coupon.program'].create({
'name': 'Buy 3 ipads, get one for free',
'promo_code_usage': 'no_code_needed',
'reward_type': 'product',
'program_type': 'promotion_program',
'reward_product_id': self.iPadMini.id,
'rule_min_quantity': 3,
'rule_products_domain': '[["name","ilike","ipad mini"]]',
})
self.p3 = self.env['sale.coupon.program'].create({
'name': 'Buy 1 computer case, get a free little server',
'promo_code_usage': 'no_code_needed',
'reward_type': 'product',
'program_type': 'promotion_program',
'reward_product_id': self.littleServer.id,
'rule_products_domain': '[["name","ilike","computer case"]]',
})
def test_program_numbers_free_and_paid_product_qty(self):
# These tests will focus on numbers (free product qty, SO total, reduction total..)
order = self.empty_order
sol1 = self.env['sale.order.line'].create({
'product_id': self.iPadMini.id,
'name': 'iPad Mini',
'product_uom_qty': 4.0,
'order_id': order.id,
})
# Check we correctly get a free product
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 2, "We should have 2 lines as we now have one 'Free iPad Mini' line as we bought 4 of them")
# Check free product's price is not added to total when applying reduction (Or the discount will also be applied on the free product's price)
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
self.assertEqual(len(order.order_line.ids), 3, "We should 3 lines as we should have a new line for promo code reduction")
self.assertEqual(order.amount_total, 864, "Only paid product should have their price discounted")
order.order_line.filtered(lambda x: 'Discount' in x.name).unlink() # Remove Discount
# Check free product is removed since we are below minimum required quantity
sol1.product_uom_qty = 3
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 1, "Free iPad Mini should have been removed")
# Free product in cart will be considered as paid product when changing quantity of paid product, so the free product quantity computation will be wrong.
# 100 iPad in cart, 25 free, set quantity to 10 ipad, you should have 2 free ipad but you get 8 because it add the 25 initial free ipad to the total paid ipad when computing (25+10 > 35 > /4 = 8 free ipad)
sol1.product_uom_qty = 100
order.recompute_coupon_lines()
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 25, "We should have 25 Free iPad Mini")
sol1.product_uom_qty = 10
order.recompute_coupon_lines()
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 2, "We should have 2 Free iPad Mini")
def test_program_numbers_check_eligibility(self):
# These tests will focus on numbers (free product qty, SO total, reduction total..)
# Check if we have enough paid product to receive free product in case of a free product that is different from the paid product required
# Buy A, get free b. (remember we need a paid B in cart to receive free b). If your cart is 4A 1B then you should receive 1b (you are eligible to receive 4 because you have 4A but since you dont have enought B in your cart, you are limited to the B quantity)
order = self.empty_order
sol1 = self.env['sale.order.line'].create({
'product_id': self.computerCase.id,
'name': 'Computer Case',
'product_uom_qty': 4.0,
'order_id': order.id,
})
sol2 = self.env['sale.order.line'].create({
'product_id': self.littleServer.id,
'name': 'Little Server',
'product_uom_qty': 1.0,
'order_id': order.id,
})
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 3, "We should have a 'Free Little Server' promotion line")
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 1, "We should receive one and only one free Little Server")
# Check the required value amount to be eligible for the program is correctly computed (eg: it does not add negative value (from free product) to total)
# A = free b | Have your cart with A 2B b | cart value should be A + 1B but in code it is only A (free b value is subsstract 2 times)
# This is because _amount_all() is summing all SO lines (so + (-b.value)) and again in _check_promo_code() order.amount_untaxed + order.reward_amount | amount_untaxed has already free product value substracted (_amount_all)
sol1.product_uom_qty = 1
sol2.product_uom_qty = 2
self.p1.rule_minimum_amount = 5000
order.recompute_coupon_lines()
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
self.assertEqual(len(order.order_line.ids), 4, "We should have 4 lines as we should have a new line for promo code reduction")
# Check you can still have auto applied promotion if you have a promo code set to the order
sol4 = self.env['sale.order.line'].create({
'product_id': self.iPadMini.id,
'name': 'iPad Mini',
'product_uom_qty': 4.0,
'order_id': order.id,
})
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 6, "We should have 2 more lines as we now have one 'Free iPad Mini' line since we bought 4 of them")
def test_program_numbers_taxes_and_rules(self):
percent_tax = self.env['account.tax'].create({
'name': "15% Tax",
'amount_type': 'percent',
'amount': 15,
'price_include': True,
})
p_specific_product = self.env['sale.coupon.program'].create({
'name': '20% reduction on ipad in cart',
'promo_code_usage': 'no_code_needed',
'reward_type': 'discount',
'program_type': 'promotion_program',
'discount_type': 'percentage',
'discount_percentage': 20.0,
'rule_minimum_amount': 320.00,
'discount_apply_on': 'specific_product',
'discount_specific_product_id': self.iPadMini.id,
})
order = self.empty_order
self.iPadMini.taxes_id = percent_tax
sol1 = self.env['sale.order.line'].create({
'product_id': self.iPadMini.id,
'name': 'iPad Mini',
'product_uom_qty': 1.0,
'order_id': order.id,
})
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 1, "We should not get the reduction line since we dont have 320$ tax excluded (ipad is 320$ tax included)")
sol1.tax_id.price_include = False
sol1._compute_tax_id()
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (ipad is 320$ tax included)")
# (320 +15% tax) - (20% of (320 + 15% tax) = 368 + 73.6 = 294.4
self.assertEqual(order.amount_total, 294.4, "Check discount has been applied correctly (eg: on taxes aswell)")
# test coupon with code works the same as auto applied_programs
p_specific_product.write({'promo_code_usage':'code_needed', 'promo_code':'20pc'})
order.order_line.filtered(lambda l: l.is_reward_line).unlink()
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 1, "Reduction should be removed since we deleted it and it is now a promo code usage, it shouldn't be automatically reapplied")
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, '20pc')
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (ipad is 320$ tax included)")
#check discount applied only on ipad
sol2 = self.env['sale.order.line'].create({
'product_id': self.computerCase.id,
'name': 'Computer Case',
'product_uom_qty': 10.0,
'order_id': order.id,
})
order.recompute_coupon_lines()
# (10x25) + (320 +15% tax) - (20% of (320 + 15% tax) = 250 + 368 - 73.6 = 544.4
self.assertEqual(order.amount_total, 544.4, "We should only get reduction on ipad")
+14 -3
View File
@@ -55,7 +55,7 @@ class TestProgramRules(TestSaleCouponCommon):
# Test case: Based on the minimum purchased
self.immediate_promotion_program.write({
'rule_minimum_amount': 1000.0,
'rule_minimum_amount': 1006,
'rule_minimum_amount_tax_inclusion': 'tax_excluded'
})
@@ -81,7 +81,7 @@ class TestProgramRules(TestSaleCouponCommon):
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'name': '10 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
}),
@@ -93,7 +93,18 @@ class TestProgramRules(TestSaleCouponCommon):
})
]})
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the purchased amount is enough")
# 10*100 + 5 = 1005
self.assertEqual(len(order.order_line.ids), 2, "The promo offert should not be applied as the purchased amount is not enough")
self.immediate_promotion_program.rule_minimum_amount = 1005
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the purchased amount is now enough")
# 10*(100*1.15) + (5*1.15) = 10*115 + 5.75 = 1155.75
self.immediate_promotion_program.rule_minimum_amount = 1006
self.immediate_promotion_program.rule_minimum_amount_tax_inclusion = 'tax_included'
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the initial amount required is now tax included")
def test_program_rules_validity_dates_and_uses(self):
# Test case: Based on the validity dates and the number of allowed uses