[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:
shag-odoo
2023-10-06 09:39:19 +00:00
committed by Victor Feyens
co-authored by Victor Feyens
parent fb320856c0
commit bf95b1f2ad
14 changed files with 291 additions and 90 deletions
@@ -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"
+1 -1
View File
@@ -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/**/*',
+10
View File
@@ -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",
+10
View File
@@ -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({
+1
View File
@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
58
59
60
61
@@ -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>
+2 -1
View File
@@ -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)
+6 -2
View File
@@ -559,7 +559,7 @@
context="{'active_test': True}"
readonly="qty_invoiced &gt; 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 -1
View File
@@ -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
+133
View File
@@ -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>