Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] sale_invoice_policy: Several improvements #3376

Open
wants to merge 8 commits into
base: 16.0
Choose a base branch
from
47 changes: 42 additions & 5 deletions sale_invoice_policy/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,57 @@ Sale invoice Policy

|badge1| |badge2| |badge3| |badge4| |badge5|

This modules helps to get Invoicing Policy on Sale Order Level without
breaking behaviour (as it is defined from >= v10 on product level).
This module adds an invoicing policy on sale order level in order to
apply that invoicing policy on the whole sale order.

That invoicing policy can take three values:

- Products Invoicing Policy: The sale order will follow the standard
behavior and apply the policy depending on products configurations.
- Ordered Quantities: The sale order will invoice the ordered quantities.
- Delivered Quantities: The sale order will invoice the delivered quantities.

Following the chosen policy, the quantity to invoice and the
amount to invoice on each line will be computed accordingly.

You will be able also to define a default invoicing policy
(globally per company)
that can be different than the default invoicing policy for new products.

**Table of contents**

.. contents::
:local:

Use Cases / Context
===================

In Odoo, products have their own invoicing policy that can be:

- Invoicing on ordered quantities
- Invoicing on ordered quantities

Following that configuration, when trying to create invoices from
sale orders, each line of product will apply its invoicing policy.

In some cases, user needs to apply an invoicing policy on a whole
sale order.

The solution proposed here is to add an invoicing policy on
sale order level.

Configuration
=============

* Go to Sale > Configuration > Settings > Sale Invoice Policy
* Choose the one that fits your needs.

Usage
=====

* Create Sale Order
* Select Invoicing Policy on Sale Order or let it void
* Either the policy selected on Sale Order would be used, either if not
filled in, the policy would be chosen from product configuration
* Select Invoicing Policy on Sale Order or let it on Products Invoicing Policy
* The created invoices will use the configuration on sale order.

Bug Tracker
===========
Expand Down Expand Up @@ -72,6 +108,7 @@ Contributors
* Luis J. Salvatierra <[email protected]>
* Alejandro Ji Cheung <[email protected]>
* Ioan Galan <[email protected]>
* Laurent Mignon <[email protected]>

Maintainers
~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions sale_invoice_policy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .hooks import pre_init_hook
from . import models
3 changes: 2 additions & 1 deletion sale_invoice_policy/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
"category": "Sales Management",
"version": "16.0.2.0.0",
"license": "AGPL-3",
"depends": ["sale_stock"],
"depends": ["sale_stock", "base_partition"],
"data": [
"views/res_config_settings_view.xml",
"views/sale_view.xml",
],
"pre_init_hook": "pre_init_hook",
}
25 changes: 25 additions & 0 deletions sale_invoice_policy/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright 2024 ACSONE SA/NV (<https://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openupgradelib import openupgrade

from odoo.api import SUPERUSER_ID, Environment


def pre_init_hook(cr):
"""
Create the sale order invoice policy with the "product" policy (standard)
but with a postgres query to avoid an update on all sale order records
"""
env = Environment(cr, SUPERUSER_ID, {})
field_spec = [
(
"invoice_policy",
"sale.order",
False,
"selection",
False,
"sale_invoice_policy",
"product",
)
]
openupgrade.add_fields(env, field_spec=field_spec)
1 change: 1 addition & 0 deletions sale_invoice_policy/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import res_config_settings
from . import sale_order
from . import sale_order_line
from . import res_company
20 changes: 20 additions & 0 deletions sale_invoice_policy/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2024 ACSONE SA/NV (<https://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

from odoo import fields, models


class ResCompany(models.Model):

_inherit = "res.company"

sale_default_invoice_policy = fields.Selection(
[
("product", "Products Invoice Policy"),
("order", "Ordered quantities"),
("delivery", "Delivered quantities"),
],
default="product",
required=True,
help="This will be the default invoice policy for sale orders.",
)
29 changes: 4 additions & 25 deletions sale_invoice_policy/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
# Copyright 2018 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

from odoo import api, fields, models
from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

sale_invoice_policy_required = fields.Boolean(
help="This makes Invoice Policy required on Sale Orders"
sale_default_invoice_policy = fields.Selection(
related="company_id.sale_default_invoice_policy",
readonly=False,
)

@api.model
def get_values(self):
res = super().get_values()
res.update(
sale_invoice_policy_required=self.env["ir.default"].get(
"res.config.settings", "sale_invoice_policy_required"
)
)
return res

def set_values(self):
super().set_values()
ir_default_obj = self.env["ir.default"]
if self.env["res.users"].has_group("base.group_erp_manager"):
ir_default_obj = ir_default_obj.sudo()
ir_default_obj.set(
"res.config.settings",
"sale_invoice_policy_required",
self.sale_invoice_policy_required,
)
return True
48 changes: 17 additions & 31 deletions sale_invoice_policy/models/sale_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,27 @@ class SaleOrder(models.Model):
_inherit = "sale.order"

invoice_policy = fields.Selection(
[("order", "Ordered quantities"), ("delivery", "Delivered quantities")],
readonly=True,
[
("product", "Products Invoice Policy"),
("order", "Ordered quantities"),
("delivery", "Delivered quantities"),
],
compute="_compute_invoice_policy",
store=True,
readonly=False,
required=True,
states={"draft": [("readonly", False)], "sent": [("readonly", False)]},
precompute=True,
help="Ordered Quantity: Invoice based on the quantity the customer "
"ordered.\n"
"Delivered Quantity: Invoiced based on the quantity the vendor "
"delivered (time or deliveries).",
)
invoice_policy_required = fields.Boolean(
compute="_compute_invoice_policy_required",
default=lambda self: self.env["ir.default"].get(
"res.config.settings", "sale_invoice_policy_required"
),
)

@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
default_invoice_policy = (
self.env["res.config.settings"]
.sudo()
.default_get(["default_invoice_policy"])
.get("default_invoice_policy", False)
)
if "invoice_policy" not in res:
res.update({"invoice_policy": default_invoice_policy})
return res

@api.depends("partner_id")
def _compute_invoice_policy_required(self):
invoice_policy_required = (
self.env["res.config.settings"]
.sudo()
.default_get(["sale_invoice_policy_required"])
.get("sale_invoice_policy_required", False)
)
for sale in self:
sale.invoice_policy_required = invoice_policy_required
@api.depends("company_id")
def _compute_invoice_policy(self) -> None:
"""
Get default sale order invoice policy
"""
for company, sale_orders in self.partition("company_id").items():
sale_orders.invoice_policy = company.sale_default_invoice_policy
113 changes: 38 additions & 75 deletions sale_invoice_policy/models/sale_order_line.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
# Copyright 2017 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from contextlib import contextmanager

from odoo import api, fields, models
from odoo import api, models


class SaleOrderLine(models.Model):
_inherit = "sale.order.line"

@api.depends(
"qty_invoiced",
"qty_delivered",
"product_uom_qty",
"state",
"order_id.invoice_policy",
)
@contextmanager
def _sale_invoice_policy(self, lines):
"""Apply the sale invoice policy to the products

This method must be called with lines sharing the same invoice policy
"""
invoice_policy = set(lines.mapped("order_id.invoice_policy"))
if len(invoice_policy) > 1:
raise Exception(
"The method _sale_invoice_policy() must be called with lines "
"sharing the same invoice policy"
)
invoice_policy = next(iter(invoice_policy))
invoice_policy_field = self.env["product.product"]._fields["invoice_policy"]
products = lines.product_id
with self.env.protecting([invoice_policy_field], products):
old_values = {}
for product in products:
old_values[product] = product.invoice_policy
product.invoice_policy = invoice_policy
yield
for product, invoice_policy in old_values.items():
product.invoice_policy = invoice_policy

@api.depends("order_id.invoice_policy")
def _compute_qty_to_invoice(self):
"""
Exclude lines that have their order invoice policy filled in
"""
other_lines = self.filtered(
lambda l: l.product_id.type == "service"
or not l.order_id.invoice_policy
or not l.order_id.invoice_policy_required
or l.order_id.invoice_policy == "product"
)
super(SaleOrderLine, other_lines)._compute_qty_to_invoice()
for line in self - other_lines:
Expand All @@ -29,74 +50,16 @@ def _compute_qty_to_invoice(self):
line.qty_to_invoice = line.qty_delivered - line.qty_invoiced
return True

@api.depends(
"state",
"price_reduce",
"product_id",
"untaxed_amount_invoiced",
"qty_delivered",
"product_uom_qty",
"order_id.invoice_policy",
)
def _compute_untaxed_amount_to_invoice(self):
@api.depends("order_id.invoice_policy")
def _compute_untaxed_amount_to_invoice(self) -> None:
other_lines = self.filtered(
lambda line: line.product_id.type == "service"
or not line.order_id.invoice_policy
or line.order_id.invoice_policy == "product"
or line.order_id.invoice_policy == line.product_id.invoice_policy
or line.state not in ["sale", "done"]
or not line.order_id.invoice_policy_required
)
super(SaleOrderLine, other_lines)._compute_untaxed_amount_to_invoice()
for line in self - other_lines:
invoice_policy = line.order_id.invoice_policy
amount_to_invoice = 0.0
price_subtotal = 0.0
uom_qty_to_consider = (
line.qty_delivered
if invoice_policy == "delivery"
else line.product_uom_qty
)
price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
price_subtotal = price_reduce * uom_qty_to_consider
if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0:
price_subtotal = line.tax_id.compute_all(
price_reduce,
currency=line.currency_id,
quantity=uom_qty_to_consider,
product=line.product_id,
partner=line.order_id.partner_shipping_id,
)["total_excluded"]
inv_lines = line._get_invoice_lines()
if any(inv_lines.mapped(lambda l: l.discount != line.discount)):
amount = 0
for inv_line in inv_lines:
if (
len(inv_line.tax_ids.filtered(lambda tax: tax.price_include))
> 0
):
amount += inv_line.tax_ids.compute_all(
inv_line.currency_id._convert(
inv_line.price_unit,
line.currency_id,
line.company_id,
inv_line.date or fields.Date.today(),
round=False,
)
* inv_line.quantity
)["total_excluded"]
else:
amount += (
inv_line.currency_id._convert(
inv_line.price_unit,
line.currency_id,
line.company_id,
inv_line.date or fields.Date.today(),
round=False,
)
* inv_line.quantity
)
amount_to_invoice = max(price_subtotal - amount, 0)
else:
amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced
line.untaxed_amount_to_invoice = amount_to_invoice
return True
for lines in (self - other_lines).partition("order_id.invoice_policy").values():
with self._sale_invoice_policy(lines):
super(SaleOrderLine, lines)._compute_untaxed_amount_to_invoice()
return
2 changes: 2 additions & 0 deletions sale_invoice_policy/readme/CONFIGURE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Go to Sale > Configuration > Settings > Sale Invoice Policy
* Choose the one that fits your needs.
13 changes: 13 additions & 0 deletions sale_invoice_policy/readme/CONTEXT.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
In Odoo, products have their own invoicing policy that can be:

- Invoicing on ordered quantities
- Invoicing on ordered quantities

Following that configuration, when trying to create invoices from
sale orders, each line of product will apply its invoicing policy.

In some cases, user needs to apply an invoicing policy on a whole
sale order.

The solution proposed here is to add an invoicing policy on
sale order level.
1 change: 1 addition & 0 deletions sale_invoice_policy/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
* Luis J. Salvatierra <[email protected]>
* Alejandro Ji Cheung <[email protected]>
* Ioan Galan <[email protected]>
* Laurent Mignon <[email protected]>
Loading
Loading