Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
4
purchase_unreconciled/models/__init__.py
Executable file
4
purchase_unreconciled/models/__init__.py
Executable file
@@ -0,0 +1,4 @@
|
||||
from . import purchase_order
|
||||
from . import company
|
||||
from . import res_config_settings
|
||||
from . import account_move_line
|
||||
90
purchase_unreconciled/models/account_move_line.py
Executable file
90
purchase_unreconciled/models/account_move_line.py
Executable 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
|
||||
)
|
||||
27
purchase_unreconciled/models/company.py
Executable file
27
purchase_unreconciled/models/company.py
Executable 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",
|
||||
)
|
||||
278
purchase_unreconciled/models/purchase_order.py
Executable file
278
purchase_unreconciled/models/purchase_order.py
Executable 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
|
||||
21
purchase_unreconciled/models/res_config_settings.py
Executable file
21
purchase_unreconciled/models/res_config_settings.py
Executable 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
|
||||
)
|
||||
Reference in New Issue
Block a user