Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user