[ADD] sale: new wizard to apply discounts on sale orders
Replace the magic js logic applying the discount of the first line to all the lines by a dedicated wizard, providing three kind of discounts: * Discount on each sale order lines * Discount on the global order amount * Fixed discount amount task-3369680 closes odoo/odoo#129087 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com> Co-authored-by: Victor Feyens <vfe@odoo.com>
This commit is contained in:
committed by
Victor Feyens
co-authored by
Victor Feyens
parent
fb320856c0
commit
bf95b1f2ad
@@ -11,9 +11,6 @@
|
||||
<field name="is_all_service" invisible="1"/>
|
||||
<field name="recompute_delivery_price" invisible="1"/>
|
||||
</field>
|
||||
<div name="so_button_below_order_lines" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</div>
|
||||
<div name="so_button_below_order_lines" position="inside">
|
||||
<button
|
||||
string="Add shipping"
|
||||
|
||||
@@ -39,6 +39,7 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'wizard/res_config_settings_views.xml',
|
||||
'wizard/sale_make_invoice_advance_views.xml',
|
||||
'wizard/sale_order_cancel_views.xml',
|
||||
'wizard/sale_order_discount_views.xml',
|
||||
|
||||
# Define sale order views before their references
|
||||
'views/sale_order_views.xml',
|
||||
@@ -68,7 +69,6 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'sale/static/src/scss/sale_onboarding.scss',
|
||||
'sale/static/src/js/sale_progressbar_field.js',
|
||||
'sale/static/src/js/tours/sale.js',
|
||||
'sale/static/src/js/product_discount_field.js',
|
||||
'sale/static/src/js/sale_product_field.js',
|
||||
'sale/static/src/js/product_catalog/**/*',
|
||||
'sale/static/src/xml/**/*',
|
||||
|
||||
@@ -27,6 +27,16 @@ class ResCompany(models.Model):
|
||||
help="Days between quotation proposal and expiration."
|
||||
" 0 days means automatic expiration is disabled",
|
||||
)
|
||||
sale_discount_product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string="Discount Product",
|
||||
domain=[
|
||||
('type', '=', 'service'),
|
||||
('invoice_policy', '=', 'order'),
|
||||
],
|
||||
help="Default product used for discounts",
|
||||
check_company=True,
|
||||
)
|
||||
sale_down_payment_product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string="Deposit Product",
|
||||
|
||||
@@ -792,6 +792,16 @@ class SaleOrder(models.Model):
|
||||
|
||||
#=== ACTION METHODS ===#
|
||||
|
||||
def action_open_discount_wizard(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _("Discount"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order.discount',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_draft(self):
|
||||
orders = self.filtered(lambda s: s.state in ['cancel', 'sent'])
|
||||
return orders.write({
|
||||
|
||||
@@ -58,3 +58,4 @@ access_sale_payment_provider_onboarding_wizard,access.sale.payment.provider.onbo
|
||||
access_sale_advance_payment_inv,access.sale.advance.payment.inv,model_sale_advance_payment_inv,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_order_cancel,access.sale.order.cancel,model_sale_order_cancel,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_mass_cancel_orders,access.sale.mass.cancel.orders,model_sale_mass_cancel_orders,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_order_discount,access_sale_order_discount,model_sale_order_discount,sales_team.group_sale_salesman,1,1,1,0
|
||||
|
||||
|
@@ -1,68 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FloatField, floatField } from "@web/views/fields/float/float_field";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
/**
|
||||
* Dialog called if user changes a value in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed
|
||||
* (3) value is the same in all other sale order line
|
||||
*/
|
||||
|
||||
export class ProductDiscountField extends FloatField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
const x2mList = this.props.record.model.root.data.order_line;
|
||||
const orderLines = x2mList.records.filter(line => !line.data.display_type);
|
||||
|
||||
if (orderLines.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstOrderLine = this.props.record.resId === orderLines[0].resId;
|
||||
if (isFirstOrderLine && sameValue(orderLines)) {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
body: _t("Do you want to apply this value to all lines?"),
|
||||
confirm: () => {
|
||||
const commands = orderLines.slice(1).map((line) => {
|
||||
return {
|
||||
operation: "UPDATE",
|
||||
record: line,
|
||||
data: {["discount"]: this.props.record.data[this.props.name]},
|
||||
};
|
||||
});
|
||||
|
||||
x2mList.applyCommands('order_line', commands);
|
||||
},
|
||||
cancel: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO remove this function, no need to export it anymore.
|
||||
export function sameValue(orderLines) {
|
||||
const compareValue = orderLines[1].data.discount;
|
||||
return orderLines.slice(1).every(line => line.data.discount === compareValue);
|
||||
}
|
||||
|
||||
|
||||
ProductDiscountField.components = { ConfirmationDialog };
|
||||
ProductDiscountField.template = "sale.ProductDiscountField";
|
||||
|
||||
export const productDiscountField = {
|
||||
...floatField,
|
||||
component: ProductDiscountField,
|
||||
displayName: _t("Disc.%"),
|
||||
};
|
||||
|
||||
registry.category("fields").add("sol_discount", productDiscountField)
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ProductDiscountField" t-inherit="web.FloatField" t-inherit-mode="primary">
|
||||
<xpath expr="//input" position="attributes">
|
||||
<attribute name="t-on-change">onChange</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -14,10 +14,11 @@ from . import test_sale_flow
|
||||
from . import test_sale_onboarding
|
||||
from . import test_sale_order
|
||||
from . import test_sale_order_cancel
|
||||
from . import test_sale_order_discount
|
||||
from . import test_sale_order_down_payment
|
||||
from . import test_sale_prices
|
||||
from . import test_sale_product_attribute_value_config
|
||||
from . import test_sale_refund
|
||||
from . import test_sale_tax_totals
|
||||
from . import test_sale_to_invoice
|
||||
from . import test_sale_order_down_payment
|
||||
from . import test_sale_report
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.fields import Command
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.sale.tests.common import SaleCommon
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSaleOrderDiscount(SaleCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.wizard = cls.env['sale.order.discount'].create({
|
||||
'sale_order_id': cls.sale_order.id,
|
||||
'discount_type': 'amount',
|
||||
})
|
||||
|
||||
def test_amount(self):
|
||||
self.wizard.write({
|
||||
'discount_amount': 55,
|
||||
'discount_type': 'amount',
|
||||
})
|
||||
self.wizard.action_apply_discount()
|
||||
|
||||
discount_line = self.sale_order.order_line[-1]
|
||||
self.assertEqual(discount_line.price_unit, -55)
|
||||
self.assertEqual(discount_line.product_uom_qty, 1.0)
|
||||
self.assertFalse(discount_line.tax_id)
|
||||
|
||||
def test_so_discount(self):
|
||||
solines = self.sale_order.order_line
|
||||
amount_before_discount = self.sale_order.amount_total
|
||||
self.assertEqual(len(solines), 2)
|
||||
|
||||
# No taxes
|
||||
solines.tax_id = [Command.clear()]
|
||||
self.wizard.write({
|
||||
'discount_percentage': 0.5, # 50%
|
||||
'discount_type': 'so_discount',
|
||||
})
|
||||
self.wizard.action_apply_discount()
|
||||
|
||||
discount_line = self.sale_order.order_line[-1]
|
||||
self.assertAlmostEqual(discount_line.price_unit, -amount_before_discount*0.5)
|
||||
self.assertFalse(discount_line.tax_id)
|
||||
self.assertEqual(discount_line.product_uom_qty, 1.0)
|
||||
|
||||
# One tax group
|
||||
discount_line.unlink()
|
||||
dumb_tax = self.env['account.tax'].create({'name': 'test'})
|
||||
solines.tax_id = dumb_tax
|
||||
self.wizard.action_apply_discount()
|
||||
|
||||
discount_line = self.sale_order.order_line - solines
|
||||
discount_line.ensure_one()
|
||||
self.assertAlmostEqual(discount_line.price_unit, -amount_before_discount*0.5)
|
||||
self.assertEqual(discount_line.tax_id, dumb_tax)
|
||||
self.assertEqual(discount_line.product_uom_qty, 1.0)
|
||||
|
||||
# Two tax groups
|
||||
discount_line.unlink()
|
||||
solines[0].tax_id = [Command.clear()]
|
||||
self.wizard.action_apply_discount()
|
||||
discount_lines = self.sale_order.order_line - solines
|
||||
self.assertEqual(len(discount_lines), 2)
|
||||
self.assertEqual(discount_lines[0].price_unit, -solines[0].price_subtotal * 0.5)
|
||||
self.assertEqual(discount_lines[1].price_unit, -solines[1].price_subtotal * 0.5)
|
||||
self.assertEqual(discount_lines[0].tax_id, solines[0].tax_id)
|
||||
self.assertEqual(discount_lines[1].tax_id, solines[1].tax_id)
|
||||
self.assertTrue(all(line.product_uom_qty == 1.0 for line in discount_lines))
|
||||
|
||||
def test_sol_discount(self):
|
||||
so_amount = self.sale_order.amount_untaxed
|
||||
self.wizard.write({
|
||||
'discount_percentage': 0.5, # 50%
|
||||
'discount_type': 'sol_discount',
|
||||
})
|
||||
self.wizard.action_apply_discount()
|
||||
|
||||
self.assertTrue(
|
||||
all(line.discount == 50 for line in self.sale_order.order_line)
|
||||
)
|
||||
self.assertAlmostEqual(self.sale_order.amount_untaxed, so_amount*0.5)
|
||||
@@ -559,7 +559,7 @@
|
||||
context="{'active_test': True}"
|
||||
readonly="qty_invoiced > 0 or is_downpayment"
|
||||
optional="show"/>
|
||||
<field name="discount" string="Disc.%" groups="product.group_discount_per_so_line" optional="show" widget="sol_discount"/>
|
||||
<field name="discount" string="Disc.%" groups="product.group_discount_per_so_line" optional="show"/>
|
||||
<field name="is_downpayment" column_invisible="True"/>
|
||||
<field name="price_subtotal" string="Tax excl." invisible="is_downpayment"/>
|
||||
<field name="price_total"
|
||||
@@ -659,8 +659,12 @@
|
||||
</kanban>
|
||||
</field>
|
||||
<div class="oe_right d-flex gap-1 mb-2 ms-1"
|
||||
invisible="1"
|
||||
name="so_button_below_order_lines">
|
||||
<button string="Discount"
|
||||
name="action_open_discount_wizard"
|
||||
type="object"
|
||||
class="btn btn-secondary"
|
||||
groups="product.group_discount_per_so_line"/>
|
||||
</div>
|
||||
<group name="note_group" col="6" class="mt-2 mt-md-0">
|
||||
<group colspan="4">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_payment_register
|
||||
from . import mass_cancel_orders
|
||||
from . import payment_link_wizard
|
||||
from . import payment_provider_onboarding_wizard
|
||||
from . import res_config_settings
|
||||
from . import sale_make_invoice_advance
|
||||
from . import sale_order_cancel
|
||||
from . import mass_cancel_orders
|
||||
from . import sale_order_discount
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import Command, _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SaleOrderDiscount(models.TransientModel):
|
||||
_name = 'sale.order.discount'
|
||||
_description = "Discount Wizard"
|
||||
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order', default=lambda self: self.env.context.get('active_id'), required=True)
|
||||
company_id = fields.Many2one(related='sale_order_id.company_id')
|
||||
currency_id = fields.Many2one(related='sale_order_id.currency_id')
|
||||
discount_amount = fields.Monetary(string="Amount")
|
||||
discount_percentage = fields.Float(string="Percentage")
|
||||
discount_type = fields.Selection(
|
||||
selection=[
|
||||
('sol_discount', "On All Order Lines"),
|
||||
('so_discount', "Global Discount"),
|
||||
('amount', "Fixed Amount"),
|
||||
],
|
||||
default='sol_discount',
|
||||
)
|
||||
|
||||
# CONSTRAINT METHODS #
|
||||
|
||||
@api.constrains('discount_type', 'discount_percentage')
|
||||
def _check_discount_amount(self):
|
||||
for wizard in self:
|
||||
if wizard.discount_type in ('sol_discount', 'so_discount') and (
|
||||
wizard.discount_percentage <= 0.0
|
||||
or wizard.discount_percentage > 1.0
|
||||
):
|
||||
raise ValidationError(_("Invalid discount amount"))
|
||||
|
||||
def _prepare_discount_product_values(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Discount'),
|
||||
'type': 'service',
|
||||
'invoice_policy': 'order',
|
||||
'list_price': 0.0,
|
||||
'company_id': self.company_id.id,
|
||||
'taxes_id': None,
|
||||
}
|
||||
|
||||
def _prepare_discount_line_values(self, product, amount, taxes, description=None):
|
||||
self.ensure_one()
|
||||
|
||||
vals = {
|
||||
'order_id': self.sale_order_id.id,
|
||||
'product_id': product.id,
|
||||
'sequence': 999,
|
||||
'price_unit': -amount,
|
||||
'tax_id': [Command.set(taxes.ids)],
|
||||
}
|
||||
if description:
|
||||
# If not given, name will fallback on the standard SOL logic (cf. _compute_name)
|
||||
vals['name'] = description
|
||||
|
||||
return vals
|
||||
|
||||
def _create_discount_lines(self):
|
||||
"""Create SOline(s) according to wizard configuration"""
|
||||
self.ensure_one()
|
||||
|
||||
discount_product = self.company_id.sale_discount_product_id
|
||||
if not discount_product:
|
||||
self.company_id.sale_discount_product_id = self.env['product.product'].create(
|
||||
self._prepare_discount_product_values()
|
||||
)
|
||||
discount_product = self.company_id.sale_discount_product_id
|
||||
|
||||
if self.discount_type == 'amount':
|
||||
vals_list = [
|
||||
self._prepare_discount_line_values(
|
||||
product=discount_product,
|
||||
amount=self.discount_amount,
|
||||
taxes=self.env['account.tax'],
|
||||
)
|
||||
]
|
||||
else: # so_discount
|
||||
total_price_per_tax_groups = defaultdict(float)
|
||||
for line in self.sale_order_id.order_line:
|
||||
if not line.product_uom_qty or not line.price_unit:
|
||||
continue
|
||||
|
||||
total_price_per_tax_groups[line.tax_id] += line.price_subtotal
|
||||
|
||||
if not total_price_per_tax_groups:
|
||||
# No valid lines on which the discount can be applied
|
||||
return
|
||||
elif len(total_price_per_tax_groups) == 1:
|
||||
# No taxes, or all lines have the exact same taxes
|
||||
taxes = next(iter(total_price_per_tax_groups.keys()))
|
||||
subtotal = total_price_per_tax_groups[taxes]
|
||||
vals_list = [{
|
||||
**self._prepare_discount_line_values(
|
||||
product=discount_product,
|
||||
amount=subtotal * self.discount_percentage,
|
||||
taxes=taxes,
|
||||
description=_(
|
||||
"Discount: %(percent)s%%",
|
||||
percent=self.discount_percentage*100
|
||||
),
|
||||
),
|
||||
}]
|
||||
else:
|
||||
vals_list = [
|
||||
self._prepare_discount_line_values(
|
||||
product=discount_product,
|
||||
amount=subtotal * self.discount_percentage,
|
||||
taxes=taxes,
|
||||
description=_(
|
||||
"Discount: %(percent)s%%"
|
||||
"- On products with the following taxes %(taxes)s",
|
||||
percent=self.discount_percentage*100,
|
||||
taxes=", ".join(taxes.mapped('name'))
|
||||
),
|
||||
) for taxes, subtotal in total_price_per_tax_groups.items()
|
||||
]
|
||||
return self.env['sale.order.line'].create(vals_list)
|
||||
|
||||
def action_apply_discount(self):
|
||||
self.ensure_one()
|
||||
self = self.with_company(self.company_id)
|
||||
if self.discount_type == 'sol_discount':
|
||||
self.sale_order_id.order_line.write({'discount': self.discount_percentage*100})
|
||||
else:
|
||||
self._create_discount_lines()
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="sale_order_line_wizard_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.line.wizard.form</field>
|
||||
<field name="model">sale.order.discount</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<group>
|
||||
<label for="discount_amount" string="Discount" invisible="discount_type != 'amount'"/>
|
||||
<field name="discount_amount" invisible="discount_type != 'amount'" nolabel="1"/>
|
||||
<label for="discount_percentage"
|
||||
string="Discount"
|
||||
invisible="discount_type not in ('so_discount', 'sol_discount')"/>
|
||||
<field name="discount_percentage"
|
||||
invisible="discount_type not in ('so_discount', 'sol_discount')"
|
||||
widget="percentage" nolabel="1"/>
|
||||
</group>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<field name="discount_type" widget="radio"/>
|
||||
</div>
|
||||
</div>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button type="object" string="Apply" name="action_apply_discount" class="btn btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" string="Discard" class="btn btn-secondary" data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -7,10 +7,7 @@
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<div name="so_button_below_order_lines" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</div>
|
||||
<div name="so_button_below_order_lines" position="inside">
|
||||
<button name="action_open_discount_wizard" position="before">
|
||||
<button name="%(sale_loyalty.sale_loyalty_coupon_wizard_action)d"
|
||||
type="action"
|
||||
string="Coupon Code"
|
||||
@@ -20,7 +17,7 @@
|
||||
string="Promotions"
|
||||
help="Update current promotional lines and select new rewards if applicable."
|
||||
class="btn btn-secondary"/>
|
||||
</div>
|
||||
</button>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user