oca-financial/odoo-bringout-oca-account-invoicing-account_global_discount/account_global_discount/models/account_move.py
2025-08-29 15:43:04 +02:00

389 lines
16 KiB
Python

# Copyright 2019 Tecnativa - David Vidal
# Copyright 2020-2021 Tecnativa - Pedro M. Baeza
# Copyright 2021 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, exceptions, fields, models
from odoo.tools import config
class AccountMove(models.Model):
_inherit = "account.move"
# HACK: Looks like UI doesn't behave well with Many2many fields and
# negative groups when the same field is shown. In this case, we want to
# show the readonly version to any not in the global discount group.
# TODO: Check if it's fixed in future versions
global_discount_ids_readonly = fields.Many2many(
string="Invoice Global Discounts (readonly)",
related="global_discount_ids",
readonly=True,
)
global_discount_ids = fields.Many2many(
comodel_name="global.discount",
column1="invoice_id",
column2="global_discount_id",
string="Invoice Global Discounts",
domain="[('discount_scope', 'in', {"
" 'out_invoice': ['sale'], "
" 'out_refund': ['sale'], "
" 'in_refund': ['purchase'], "
" 'in_invoice': ['purchase']"
"}.get(move_type, [])), ('account_id', '!=', False), '|', "
"('company_id', '=', company_id), ('company_id', '=', False)]",
readonly=True,
states={"draft": [("readonly", False)]},
)
amount_global_discount = fields.Monetary(
string="Total Global Discounts",
compute="_compute_amount",
currency_field="currency_id",
readonly=True,
compute_sudo=True,
store=True,
)
amount_untaxed_before_global_discounts = fields.Monetary(
string="Amount Untaxed Before Discounts",
compute="_compute_amount",
currency_field="currency_id",
readonly=True,
compute_sudo=True,
store=True,
)
invoice_global_discount_ids = fields.One2many(
comodel_name="account.invoice.global.discount",
inverse_name="invoice_id",
readonly=True,
)
def _prepare_global_discount_vals(self, global_discount, base, tax_ids):
"""Prepare the dictionary values for an invoice global discount
line.
"""
self.ensure_one()
discount = global_discount._get_global_discount_vals(base)
return {
"name": global_discount.display_name,
"invoice_id": self.id,
"global_discount_id": global_discount.id,
"discount": global_discount.discount,
"base": base,
"base_discounted": discount["base_discounted"],
"account_id": global_discount.account_id.id,
"tax_ids": [(4, tax_id) for tax_id in tax_ids],
}
def _set_global_discounts_by_tax(self):
"""Create invoice global discount lines by taxes combinations and
discounts.
This also resets previous global discounts in case they existed.
"""
self.ensure_one()
if not self.is_invoice():
return
in_draft_mode = self != self._origin
taxes_keys = {}
# Perform a sanity check for discarding cases that will lead to
# incorrect data in discounts
_self = self.filtered("global_discount_ids")
for inv_line in _self.invoice_line_ids.filtered(
lambda l: l.display_type not in ["line_section", "line_note"]
):
if inv_line.product_id.bypass_global_discount:
continue
taxes_keys.setdefault(tuple(inv_line.tax_ids.ids), 0)
taxes_keys[tuple(inv_line.tax_ids.ids)] += inv_line.price_subtotal
# Reset previous global discounts
self.invoice_global_discount_ids -= self.invoice_global_discount_ids
model = "account.invoice.global.discount"
create_method = in_draft_mode and self.env[model].new or self.env[model].create
for tax_line in _self.line_ids.filtered("tax_line_id"):
key = []
discount_line_base = 0
for key in taxes_keys:
if tax_line.tax_line_id.id in key:
discount_line_base = taxes_keys[key]
taxes_keys[key] = 0 # mark for not duplicating
break # we leave in key variable the proper taxes value
if not discount_line_base:
continue
for global_discount in self.global_discount_ids:
vals = self._prepare_global_discount_vals(
global_discount, discount_line_base, key
)
create_method(vals)
discount_line_base = vals["base_discounted"]
_self._set_global_discounts_by_zero_tax(taxes_keys, create_method)
def _set_global_discounts_by_zero_tax(self, taxes_keys, create_method):
# Check all moves with defined taxes to check if there's any discount not
# created (tax amount is zero and only one tax is applied)
base_total = 0
zero_taxes = self.env["account.tax"]
for line in self.line_ids.filtered("tax_ids"):
if line.product_id.bypass_global_discount:
continue
key = tuple(line.tax_ids.ids)
if taxes_keys.get(key):
base_total += line.price_subtotal
zero_taxes |= line.tax_ids
for global_discount in self.global_discount_ids:
if not base_total:
break
vals = self._prepare_global_discount_vals(
global_discount, base_total, zero_taxes.ids
)
create_method(vals)
base_total = vals["base_discounted"]
def _recompute_global_discount_lines(self):
"""Append global discounts move lines.
This is called when recomputing dynamic lines before calling
`_recompute_payment_terms_lines`, but after calling `_recompute_tax_lines`.
"""
self.ensure_one()
in_draft_mode = self != self._origin
model = "account.move.line"
create_method = in_draft_mode and self.env[model].new or self.env[model].create
for discount in self.invoice_global_discount_ids.filtered("discount"):
sign = -1 if self.move_type in {"in_invoice", "out_refund"} else 1
disc_amount = sign * discount.discount_amount
disc_amount_company_currency = disc_amount
if self.currency_id != self.company_id.currency_id:
disc_amount_company_currency = self.currency_id._convert(
disc_amount,
self.company_id.currency_id,
self.company_id,
self.date or fields.Date.context_today(self),
)
create_method(
{
"invoice_global_discount_id": discount.id,
"move_id": self.id,
"name": "%s - %s"
% (discount.name, ", ".join(discount.tax_ids.mapped("name"))),
"debit": disc_amount_company_currency > 0.0
and disc_amount_company_currency
or 0.0,
"credit": disc_amount_company_currency < 0.0
and -disc_amount_company_currency
or 0.0,
"amount_currency": (disc_amount > 0.0 and disc_amount or 0.0)
- (disc_amount < 0.0 and -disc_amount or 0.0),
"account_id": discount.account_id.id,
"tax_ids": [(4, x.id) for x in discount.tax_ids],
"partner_id": self.commercial_partner_id.id,
"currency_id": self.currency_id.id,
"price_unit": -1 * abs(disc_amount_company_currency),
}
)
@api.onchange("partner_id", "company_id")
def _onchange_partner_id(self):
res = super()._onchange_partner_id()
discounts = False
if (
self.move_type in ["out_invoice", "out_refund"]
and self.partner_id.customer_global_discount_ids
):
discounts = self.partner_id.customer_global_discount_ids.filtered(
lambda d: d.company_id == self.company_id
)
elif (
self.move_type in ["in_refund", "in_invoice"]
and self.partner_id.supplier_global_discount_ids
):
discounts = self.partner_id.supplier_global_discount_ids.filtered(
lambda d: d.company_id == self.company_id
)
if discounts:
self.global_discount_ids = discounts
return res
def _compute_amount_one(self):
"""Perform totals computation of a move with global discounts."""
if not self.invoice_global_discount_ids:
self.amount_global_discount = 0.0
self.amount_untaxed_before_global_discounts = 0.0
return
round_curr = self.currency_id.round
self.amount_global_discount = sum(
round_curr(discount.discount_amount) * -1
for discount in self.invoice_global_discount_ids
)
self.amount_untaxed_before_global_discounts = (
self.amount_untaxed - self.amount_global_discount
)
@api.depends(
"line_ids.matched_debit_ids.debit_move_id.move_id.payment_id.is_matched",
"line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual",
"line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual_currency",
"line_ids.matched_credit_ids.credit_move_id.move_id.payment_id.is_matched",
"line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual",
"line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual_currency",
"line_ids.balance",
"line_ids.currency_id",
"line_ids.amount_currency",
"line_ids.amount_residual",
"line_ids.amount_residual_currency",
"line_ids.payment_id.state",
"line_ids.full_reconcile_id",
"invoice_global_discount_ids",
"global_discount_ids",
)
def _compute_amount(self):
"""Modify totals computation for including global discounts."""
res = super()._compute_amount()
for record in self:
record._compute_amount_one()
return res
def _clean_global_discount_lines(self):
self.ensure_one()
gbl_disc_lines = self.env["account.move.line"].search(
[
("move_id", "=", self.id),
"|",
("global_discount_item", "=", True),
("invoice_global_discount_id", "!=", False),
]
)
if gbl_disc_lines:
move_container = {"records": self}
with self._check_balanced(move_container), self._sync_dynamic_lines(
move_container
):
gbl_disc_lines.unlink()
@api.model_create_multi
def create(self, vals_list):
"""If we create the invoice with the discounts already set like from
a sales order, we must compute the global discounts as well, as some data
like ``tax_ids`` is not set until the final step.
"""
moves = super().create(vals_list)
for move in moves:
if move.move_type in ["out_refund", "in_refund"]:
move._clean_global_discount_lines()
move._set_global_discounts_by_tax()
move._recompute_global_discount_lines()
return moves
def write(self, vals):
res = super().write(vals)
if "invoice_line_ids" in vals or "global_discount_ids" in vals:
for move in self:
move._clean_global_discount_lines()
move._set_global_discounts_by_tax()
move._recompute_global_discount_lines()
move_container = {"records": self}
self._global_discount_check(move_container)
return res
def _global_discount_check(self, container):
test_condition = not config["test_enable"] or self.env.context.get(
"test_account_global_discount"
)
for move in container["records"]:
if not move.is_invoice() or not move.global_discount_ids:
continue
taxes_keys = {}
for inv_line in move.invoice_line_ids:
if inv_line.display_type != "product":
continue
if not inv_line.tax_ids and test_condition:
raise exceptions.UserError(
_("With global discounts, taxes in lines are required.")
)
for key in taxes_keys:
if key == tuple(inv_line.tax_ids.ids):
break
elif set(key) & set(inv_line.tax_ids.ids) and test_condition:
raise exceptions.UserError(
_("Incompatible taxes found for global discounts.")
)
else:
taxes_keys[tuple(inv_line.tax_ids.ids)] = True
return True
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
invoice_global_discount_id = fields.Many2one(
comodel_name="account.invoice.global.discount",
string="Invoice Global Discount",
)
base_before_global_discounts = fields.Monetary(
string="Amount Untaxed Before Discounts",
readonly=True,
)
# TODO: To be removed on future versions if invoice_global_discount_id is properly filled
# Provided for compatibility in stable branch
# UPD: can be removed past version 16.0
global_discount_item = fields.Boolean()
class AccountInvoiceGlobalDiscount(models.Model):
_name = "account.invoice.global.discount"
_description = "Invoice Global Discount"
name = fields.Char(string="Discount Name", required=True)
invoice_id = fields.Many2one(
"account.move",
string="Invoice",
ondelete="cascade",
index=True,
readonly=True,
domain=[
(
"move_type",
"in",
["out_invoice", "out_refund", "in_invoice", "in_refund"],
)
],
)
global_discount_id = fields.Many2one(
comodel_name="global.discount",
string="Global Discount",
)
discount = fields.Float(string="Discount (number)")
discount_display = fields.Char(
compute="_compute_discount_display",
string="Discount",
)
base = fields.Float(string="Base before discount", digits="Product Price")
base_discounted = fields.Float(string="Base after discount", digits="Product Price")
currency_id = fields.Many2one(related="invoice_id.currency_id", readonly=True)
discount_amount = fields.Monetary(
string="Discounted Amount",
compute="_compute_discount_amount",
currency_field="currency_id",
compute_sudo=True,
)
tax_ids = fields.Many2many(comodel_name="account.tax", string="Taxes")
account_id = fields.Many2one(
comodel_name="account.account",
required=True,
string="Account",
domain="[('account_type', 'not in', ['asset_receivable', 'liability_payable'])]",
)
account_analytic_id = fields.Many2one(
comodel_name="account.analytic.account",
string="Analytic account",
)
company_id = fields.Many2one(related="invoice_id.company_id", readonly=True)
def _compute_discount_display(self):
"""Given a discount type, we need to render a different symbol"""
for one in self:
precision = self.env["decimal.precision"].precision_get("Discount")
one.discount_display = "{0:.{1}f}%".format(one.discount * -1, precision)
@api.depends("base", "base_discounted")
def _compute_discount_amount(self):
"""Compute the amount discounted"""
for one in self:
one.discount_amount = one.base - one.base_discounted