Initial commit: Odoo 18.0-20251222 extra-addons
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
tocmo0nlord
2026-03-13 20:43:25 +00:00
parent 36e847a7df
commit adbe430761
9472 changed files with 1265727 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from . import purchase_order
from . import company
from . import res_config_settings
from . import account_move_line

View File

@@ -0,0 +1,90 @@
# Copyright 2019-21 ForgeFlow S.L.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from datetime import datetime
from odoo import _, models
from odoo.exceptions import ValidationError
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
def _get_writeoff_amounts(self):
precision = self.env["decimal.precision"].precision_get("Account")
writeoff_amount = round(
sum(line["amount_residual"] for line in self), precision
)
writeoff_amount_curr = round(
sum(line["amount_residual_currency"] for line in self), precision
)
if writeoff_amount_curr and not writeoff_amount:
# Data inconsistency, do not create the write-off
return (0.0, 0.0, True)
first_currency = self[0]["currency_id"]
if all([line["currency_id"] == first_currency for line in self]):
same_curr = True
else:
same_curr = False
return (
writeoff_amount,
writeoff_amount_curr,
same_curr,
)
def _create_writeoff(self, writeoff_vals):
(
amount_writeoff,
amount_writeoff_curr,
same_curr,
) = self._get_writeoff_amounts()
if not amount_writeoff:
return self.env["account.move.line"]
partners = self.mapped("partner_id")
move_date = writeoff_vals.get("date", datetime.now())
write_off_vals = {
"name": _("Automatic writeoff"),
"amount_currency": same_curr and amount_writeoff_curr or amount_writeoff,
"debit": amount_writeoff > 0.0 and amount_writeoff or 0.0,
"credit": amount_writeoff < 0.0 and -amount_writeoff or 0.0,
"partner_id": len(partners) == 1 and partners.id or False,
"account_id": writeoff_vals["account_id"],
"date": move_date,
"journal_id": writeoff_vals["journal_id"],
"currency_id": writeoff_vals.get("currency_id", False),
"product_id": writeoff_vals["product_id"],
"purchase_order_id": writeoff_vals["purchase_order_id"],
"purchase_line_id": writeoff_vals["purchase_line_id"],
}
counterpart_account = self.mapped("account_id")
if len(counterpart_account) != 1:
raise ValidationError(_("Cannot write-off more than one account"))
counter_part = write_off_vals.copy()
counter_part["debit"] = write_off_vals["credit"]
counter_part["credit"] = write_off_vals["debit"]
counter_part["amount_currency"] = -write_off_vals["amount_currency"]
counter_part["account_id"] = (counterpart_account.id,)
move = self.env["account.move"].create(
{
"date": move_date,
"journal_id": writeoff_vals["journal_id"],
"currency_id": writeoff_vals.get("currency_id", False),
"line_ids": [(0, 0, write_off_vals), (0, 0, counter_part)],
}
)
if writeoff_vals.get("purchase_order_id", False):
# done this way because purchase_order_id is a related field and will
# not being assign on create. Cannot assign purchase_line_id because
# it is a generic write-off for the whole PO
self.env.cr.execute(
"""UPDATE account_move_line SET purchase_order_id = %s
WHERE id in %s
""",
(writeoff_vals["purchase_order_id"], tuple(move.line_ids.ids)),
)
move.action_post()
return move.line_ids.filtered(
lambda line: line.account_id.id == counterpart_account.id
)

View File

@@ -0,0 +1,27 @@
# Copyright 2019-21 ForgeFlow S.L.
# 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"
purchase_reconcile_account_id = fields.Many2one(
"account.account",
domain=lambda self: [("deprecated", "=", False)],
string="Write-Off Account On Purchases",
ondelete="restrict",
copy=False,
help="Write-off account to reconcile Unreconciled Purchase Orders",
)
purchase_reconcile_journal_id = fields.Many2one(
"account.journal", string="WriteOff Journal for Purchases"
)
purchase_lock_auto_reconcile = fields.Boolean()
purchase_reconcile_tolerance = fields.Float(
string="Purchase Reconcile Tolerance (%)",
default=0.0,
help="Percentage of tolerance of residual amount vs total amount of the "
"Purchase Order. Leave zero to accept all discrepancies",
)

View File

@@ -0,0 +1,278 @@
# Copyright 2019-21 ForgeFlow S.L..
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import _, api, exceptions, fields, models
from odoo.osv import expression
from odoo.tools import float_is_zero
class PurchaseOrder(models.Model):
_inherit = "purchase.order"
unreconciled = fields.Boolean(
compute="_compute_unreconciled",
search="_search_unreconciled",
help="Indicates that a Purchase Order has related Journal items not "
"reconciled.\nNote that if it is false it can be either that "
"everything is reconciled or that the related accounts do not "
"allow reconciliation",
)
amount_unreconciled = fields.Float(compute="_compute_unreconciled")
def _get_account_domain(self):
included_accounts = (
(
self.env["product.category"]
.with_company(self.company_id.id)
.search([("property_valuation", "=", "real_time")])
)
.mapped("property_stock_account_input_categ_id")
.ids
)
return [("account_id", "in", included_accounts)]
@api.model
def _get_purchase_unreconciled_base_domain(self):
unreconciled_domain = [
("account_id.reconcile", "=", True),
("move_id.state", "=", "posted"),
("company_id", "in", self.env.companies.ids),
# same condition than Odoo Unreconciled filter
("amount_residual", "!=", 0.0),
("balance", "!=", 0.0),
]
return unreconciled_domain
def _compute_unreconciled(self):
acc_item = self.env["account.move.line"]
for rec in self:
domain = rec.with_company(
rec.company_id
)._get_purchase_unreconciled_base_domain()
domain_account = rec._get_account_domain()
unreconciled_domain = expression.AND([domain, domain_account])
unreconciled_domain = expression.AND(
[unreconciled_domain, [("purchase_order_id", "=", rec.id)]]
)
unreconciled_items = acc_item.search(unreconciled_domain)
rec.unreconciled = len(unreconciled_items) > 0
rec.amount_unreconciled = sum(unreconciled_items.mapped("amount_residual"))
def _search_unreconciled(self, operator, value):
if operator not in ("=", "!=") or not isinstance(value, bool):
raise ValueError(_("Unsupported search operator"))
acc_item = self.env["account.move.line"]
domain = self._get_purchase_unreconciled_base_domain()
domain = expression.AND([domain, [("purchase_order_id", "!=", False)]])
domain_account = self._get_account_domain()
domain = expression.AND([domain_account, domain])
acc_items = acc_item.search(domain)
unreconciled_pos_ids = acc_items.mapped("purchase_order_id").ids
if value:
return [("id", "in", unreconciled_pos_ids)]
else:
return [("id", "not in", unreconciled_pos_ids)]
def action_view_unreconciled(self):
self.ensure_one()
acc_item = self.env["account.move.line"]
domain = self.with_company(
self.company_id.id
)._get_purchase_unreconciled_base_domain()
domain_account = self._get_account_domain()
unreconciled_domain = expression.AND([domain, domain_account])
unreconciled_domain = expression.AND(
[unreconciled_domain, [("purchase_order_id", "=", self.id)]]
)
unreconciled_domain.remove(("amount_residual", "!=", 0.0))
unreconciled_domain.remove("&")
unreconciled_items = acc_item.search(unreconciled_domain)
action = self.env.ref("account.action_account_moves_all")
action_dict = action.read()[0]
action_dict["domain"] = [("id", "in", unreconciled_items.ids)]
return action_dict
def action_reconcile(self):
if (
not self.company_id.purchase_reconcile_account_id
or not self.company_id.purchase_reconcile_journal_id
):
raise exceptions.ValidationError(
_(
"The write-off account and journal for purchases is missing. An "
"accountant must fill that information"
)
)
self.ensure_one()
res = {}
domain = self._get_purchase_unreconciled_base_domain()
domain_account = self._get_account_domain()
unreconciled_domain = expression.AND([domain, domain_account])
unreconciled_domain = expression.AND(
[domain, [("purchase_order_id", "=", self.id)]]
)
unreconciled_domain = expression.AND(
[unreconciled_domain, [("company_id", "=", self.company_id.id)]]
)
writeoff_to_reconcile = self.env["account.move.line"]
all_writeoffs = self.env["account.move.line"]
reconciling_groups = self.env["account.move.line"].read_group(
domain=unreconciled_domain,
fields=["account_id", "product_id", "oca_purchase_line_id"],
groupby=["account_id", "product_id", "oca_purchase_line_id"],
lazy=False,
)
unreconciled_items = self.env["account.move.line"].search(unreconciled_domain)
for group in reconciling_groups:
account_id = group["account_id"][0]
product_id = group["product_id"][0] if group["product_id"] else False
purchase_line_id = (
group["oca_purchase_line_id"][0]
if group["oca_purchase_line_id"]
else False
)
unreconciled_items_group = unreconciled_items.filtered(
lambda line, account_id=account_id, product_id=product_id: (
line.account_id.id == account_id
and line.product_id.id == product_id
)
)
# Check which type of force reconciling we are doing:
# - Force reconciling amount_residual
# - Force reconciling amount_residual_currency
amount_residual_currency_reconcile = any(
unreconciled_items_group.filtered(
lambda item_group,
account_id=account_id: item_group.amount_residual_currency != 0.0
and item_group.account_id.id == account_id
)
)
if amount_residual_currency_reconcile:
residual_field = "amount_residual_currency"
else:
residual_field = "amount_residual"
if float_is_zero(
sum(unreconciled_items_group.mapped(residual_field)),
precision_rounding=self.company_id.currency_id.rounding,
):
moves_to_reconcile = unreconciled_items_group
else:
writeoff_vals = self._get_purchase_writeoff_vals(
unreconciled_items_group, purchase_line_id, product_id
)
writeoff_to_reconcile = unreconciled_items_group._create_writeoff(
writeoff_vals
)
all_writeoffs |= writeoff_to_reconcile
# add writeoff line to reconcile algorithm and finish the reconciliation
moves_to_reconcile = unreconciled_items_group | writeoff_to_reconcile
# Check if reconciliation is total or needs an exchange rate entry to be
# created
if moves_to_reconcile:
moves_to_reconcile.filtered(
lambda move: not move.reconciled
).reconcile()
reconciled_ids = unreconciled_items | all_writeoffs
res = {
"name": _("Reconciled journal items"),
"type": "ir.actions.act_window",
"view_type": "form",
"view_mode": "list,form",
"res_model": "account.move.line",
"domain": [("id", "in", reconciled_ids.ids)],
}
if self.env.context.get("bypass_unreconciled", False):
# When calling the method from the wizard, lock after reconciling
self.button_done()
return res
def _get_purchase_writeoff_vals(self, amls, purchase_line_id, product_id):
writeoff_date = self.env.context.get("writeoff_date", False)
aml_date = max(amls.mapped("move_id.date"))
res = {
"account_id": self.company_id.purchase_reconcile_account_id.id,
"journal_id": self.company_id.purchase_reconcile_journal_id.id,
"purchase_order_id": self.id,
"purchase_line_id": purchase_line_id or False,
"product_id": product_id,
"currency_id": self.currency_id.id or self.env.company.currency_id.id,
"date": aml_date,
}
# hook for custom date:
if writeoff_date:
res.update({"date": writeoff_date})
return res
def reconcile_criteria(self):
"""Gets the criteria where POs are locked or not, by default uses the company
configuration"""
self.ensure_one()
return self.unreconciled and self.company_id.purchase_lock_auto_reconcile
def button_done(self):
for rec in self:
criteria = rec.reconcile_criteria()
if criteria:
if rec.unreconciled:
exception_msg = rec.unreconciled_exception_msg()
if exception_msg:
res = rec.purchase_unreconciled_exception(exception_msg)
return res
else:
rec.action_reconcile()
return super(PurchaseOrder, rec).button_done()
else:
return super(PurchaseOrder, rec).button_done()
else:
return super(PurchaseOrder, rec).button_done()
def purchase_unreconciled_exception(self, exception_msg=None):
"""This mean to be run when the SO cannot be reconciled because it is over
tolerance"""
self.ensure_one()
if exception_msg:
return (
self.env["purchase.unreconciled.exceeded.wiz"]
.create(
{
"exception_msg": exception_msg,
"purchase_id": self.id,
"origin_reference": "{},{}".format("purchase.order", self.id),
"continue_method": "action_reconcile",
}
)
.action_show()
)
def unreconciled_exception_msg(self):
self.ensure_one()
exception_msg = ""
amount_total = self.amount_total
if self.currency_id and self.company_id.currency_id != self.currency_id:
amount_total = self.currency_id._convert(
amount_total,
self.company_id.currency_id,
self.company_id,
fields.Date.today(),
)
if (
self.company_id.purchase_reconcile_tolerance
and amount_total
and abs(self.amount_unreconciled / amount_total)
>= self.company_id.purchase_reconcile_tolerance / 100.0
):
params = {
"amount_unreconciled": self.amount_unreconciled,
"amount_allowed": self.amount_total
* self.company_id.purchase_reconcile_tolerance
/ 100.0,
}
exception_msg = (
_(
"Finance Warning: \nUnreconciled amount is too high. Total "
"unreconciled amount: %(amount_unreconciled)s Maximum unreconciled"
" amount accepted: %(amount_allowed)s "
)
% params
)
return exception_msg

View File

@@ -0,0 +1,21 @@
# Copyright 2019-21 ForgeFlow S.L..
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
purchase_reconcile_account_id = fields.Many2one(
related="company_id.purchase_reconcile_account_id", readonly=False
)
purchase_reconcile_journal_id = fields.Many2one(
related="company_id.purchase_reconcile_journal_id", readonly=False
)
purchase_lock_auto_reconcile = fields.Boolean(
related="company_id.purchase_lock_auto_reconcile", readonly=False
)
purchase_reconcile_tolerance = fields.Float(
related="company_id.purchase_reconcile_tolerance", readonly=False
)