diff --git a/purchase_unreconciled/README.rst b/purchase_unreconciled/README.rst new file mode 100644 index 000000000..b3b03f9b8 --- /dev/null +++ b/purchase_unreconciled/README.rst @@ -0,0 +1,108 @@ +===================== +Purchase Unreconciled +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8134013d5d37fe8931266d6f916bbc160f48212ac4a9faa43b4efe39d03e6b4c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--financial--tools-lightgray.png?logo=github + :target: https://github.com/OCA/account-financial-tools/tree/17.0/purchase_unreconciled + :alt: OCA/account-financial-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-financial-tools-17-0/account-financial-tools-17-0-purchase_unreconciled + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-financial-tools&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a new fields "Unreconciled" on Purchase Orders, that +allows to find PO's with unreconciled journal items related. + +This module allows to reconcile those PO in a single click. In +accounting settings users will be able to set up a specific account for +write-off. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Accountants will be able to find a filters in Purchase Orders that shows +outstanding balances in interim accounts. Also there is a link in the PO +to those outstanding journal items. + +Locking the PO will automatically reconcile the outstanding balance for +the stock iterim accounts. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow S.L. + +Contributors +------------ + +- ForgeFlow S.L. + + - Lois Rilo + - Aaron Henriquez + - Miquel Raich + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-AaronHForgeFlow| image:: https://github.com/AaronHForgeFlow.png?size=40px + :target: https://github.com/AaronHForgeFlow + :alt: AaronHForgeFlow + +Current `maintainer `__: + +|maintainer-AaronHForgeFlow| + +This module is part of the `OCA/account-financial-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/purchase_unreconciled/__init__.py b/purchase_unreconciled/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/purchase_unreconciled/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/purchase_unreconciled/__manifest__.py b/purchase_unreconciled/__manifest__.py new file mode 100644 index 000000000..9b6e21097 --- /dev/null +++ b/purchase_unreconciled/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2019-22 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Purchase Unreconciled", + "version": "17.0.1.0.0", + "author": "ForgeFlow S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-financial-tools", + "category": "Purchases", + "depends": ["account_move_line_purchase_info", "purchase_stock"], + "data": [ + "security/ir.model.access.csv", + "views/purchase_order_view.xml", + "views/res_config_settings_view.xml", + "wizards/purchase_unreconciled_exceeded_view.xml", + ], + "license": "AGPL-3", + "installable": True, + "development_status": "Alpha", + "maintainers": ["AaronHForgeFlow"], +} diff --git a/purchase_unreconciled/models/__init__.py b/purchase_unreconciled/models/__init__.py new file mode 100644 index 000000000..925c5320e --- /dev/null +++ b/purchase_unreconciled/models/__init__.py @@ -0,0 +1,4 @@ +from . import purchase_order +from . import company +from . import res_config_settings +from . import account_move_line diff --git a/purchase_unreconciled/models/account_move_line.py b/purchase_unreconciled/models/account_move_line.py new file mode 100644 index 000000000..6c58a9969 --- /dev/null +++ b/purchase_unreconciled/models/account_move_line.py @@ -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 + ) diff --git a/purchase_unreconciled/models/company.py b/purchase_unreconciled/models/company.py new file mode 100644 index 000000000..3fb456129 --- /dev/null +++ b/purchase_unreconciled/models/company.py @@ -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", + ) diff --git a/purchase_unreconciled/models/purchase_order.py b/purchase_unreconciled/models/purchase_order.py new file mode 100644 index 000000000..4e3fb7e1e --- /dev/null +++ b/purchase_unreconciled/models/purchase_order.py @@ -0,0 +1,276 @@ +# 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", "purchase_line_id"], + groupby=["account_id", "product_id", "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["purchase_line_id"][0] if group["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": "tree,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 diff --git a/purchase_unreconciled/models/res_config_settings.py b/purchase_unreconciled/models/res_config_settings.py new file mode 100644 index 000000000..afce10572 --- /dev/null +++ b/purchase_unreconciled/models/res_config_settings.py @@ -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 + ) diff --git a/purchase_unreconciled/pyproject.toml b/purchase_unreconciled/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/purchase_unreconciled/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/purchase_unreconciled/readme/CONTRIBUTORS.md b/purchase_unreconciled/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..cccfd01f8 --- /dev/null +++ b/purchase_unreconciled/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- ForgeFlow S.L. \<\> + - Lois Rilo \<\> + - Aaron Henriquez \<\> + - Miquel Raich \<\> diff --git a/purchase_unreconciled/readme/DESCRIPTION.md b/purchase_unreconciled/readme/DESCRIPTION.md new file mode 100644 index 000000000..4ac421246 --- /dev/null +++ b/purchase_unreconciled/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This module adds a new fields "Unreconciled" on Purchase Orders, that +allows to find PO's with unreconciled journal items related. + +This module allows to reconcile those PO in a single click. In +accounting settings users will be able to set up a specific account for +write-off. diff --git a/purchase_unreconciled/readme/USAGE.md b/purchase_unreconciled/readme/USAGE.md new file mode 100644 index 000000000..7bc7c0ed4 --- /dev/null +++ b/purchase_unreconciled/readme/USAGE.md @@ -0,0 +1,6 @@ +Accountants will be able to find a filters in Purchase Orders that shows +outstanding balances in interim accounts. Also there is a link in the PO +to those outstanding journal items. + +Locking the PO will automatically reconcile the outstanding balance for +the stock iterim accounts. diff --git a/purchase_unreconciled/security/ir.model.access.csv b/purchase_unreconciled/security/ir.model.access.csv new file mode 100644 index 000000000..11a3d6bf3 --- /dev/null +++ b/purchase_unreconciled/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +purchase.unreconciled.exceeded.wiz,purchase.unreconciled.exceeded.wiz,purchase_unreconciled.model_purchase_unreconciled_exceeded_wiz,base.group_user,1,1,1,1 diff --git a/purchase_unreconciled/static/description/index.html b/purchase_unreconciled/static/description/index.html new file mode 100644 index 000000000..78b2b102c --- /dev/null +++ b/purchase_unreconciled/static/description/index.html @@ -0,0 +1,449 @@ + + + + + +Purchase Unreconciled + + + +
+

Purchase Unreconciled

+ + +

Alpha License: AGPL-3 OCA/account-financial-tools Translate me on Weblate Try me on Runboat

+

This module adds a new fields “Unreconciled” on Purchase Orders, that +allows to find PO’s with unreconciled journal items related.

+

This module allows to reconcile those PO in a single click. In +accounting settings users will be able to set up a specific account for +write-off.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

Accountants will be able to find a filters in Purchase Orders that shows +outstanding balances in interim accounts. Also there is a link in the PO +to those outstanding journal items.

+

Locking the PO will automatically reconcile the outstanding balance for +the stock iterim accounts.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow S.L.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

AaronHForgeFlow

+

This module is part of the OCA/account-financial-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/purchase_unreconciled/tests/__init__.py b/purchase_unreconciled/tests/__init__.py new file mode 100644 index 000000000..57ca5d6a8 --- /dev/null +++ b/purchase_unreconciled/tests/__init__.py @@ -0,0 +1 @@ +from . import test_purchase_unreconciled diff --git a/purchase_unreconciled/tests/test_purchase_unreconciled.py b/purchase_unreconciled/tests/test_purchase_unreconciled.py new file mode 100644 index 000000000..c244b7dfc --- /dev/null +++ b/purchase_unreconciled/tests/test_purchase_unreconciled.py @@ -0,0 +1,427 @@ +# 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 exceptions, fields +from odoo.tests.common import Form, SingleTransactionCase + + +class TestPurchaseUnreconciled(SingleTransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.po_obj = cls.env["purchase.order"] + cls.product_obj = cls.env["product.product"] + cls.category_obj = cls.env["product.category"] + cls.partner_obj = cls.env["res.partner"] + cls.acc_obj = cls.env["account.account"] + cls.account_move_obj = cls.env["account.move"] + cls.company = cls.env.ref("base.main_company") + cls.company.anglo_saxon_accounting = True + expense_type = "expense" + equity_type = "equity" + asset_type = "asset_current" + # Create partner: + cls.partner = cls.partner_obj.create({"name": "Test Vendor"}) + # Create product that uses a reconcilable stock input account. + cls.account = cls.acc_obj.create( + { + "name": "Test stock input account", + "code": 9999, + "account_type": asset_type, + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.writeoff_acc = cls.acc_obj.create( + { + "name": "Write-offf account", + "code": 8888, + "account_type": expense_type, + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.stock_journal = cls.env["account.journal"].create( + {"name": "Stock Journal", "code": "STJTEST", "type": "general"} + ) + # Create account for Goods Received Not Invoiced + name = "Goods Received Not Invoiced" + code = "grni" + acc_type = equity_type + cls.account_grni = cls._create_account( + acc_type, name, code, cls.company, reconcile=True + ) + # Create account for Cost of Goods Sold + name = "Cost of Goods Sold" + code = "cogs" + acc_type = expense_type + cls.account_cogs = cls._create_account(acc_type, name, code, cls.company) + # Create account for Goods Delivered Not Invoiced + name = "Goods Delivered Not Invoiced" + code = "gdni" + acc_type = expense_type + cls.account_gdni = cls._create_account( + acc_type, name, code, cls.company, reconcile=True + ) + # Create account for Inventory + name = "Inventory" + code = "inventory" + acc_type = asset_type + cls.account_inventory = cls._create_account(acc_type, name, code, cls.company) + cls.product_categ = cls.category_obj.create( + { + "name": "Test Category", + "property_cost_method": "standard", + "property_stock_valuation_account_id": cls.account_inventory.id, + "property_stock_account_input_categ_id": cls.account_grni.id, + "property_account_expense_categ_id": cls.account_cogs.id, + "property_stock_account_output_categ_id": cls.account_gdni.id, + "property_valuation": "real_time", + "property_stock_journal": cls.stock_journal.id, + } + ) + cls.product_to_reconcile = cls.product_obj.create( + { + "name": "Purchased Product (To reconcile)", + "type": "product", + "standard_price": 100.0, + "categ_id": cls.product_categ.id, + } + ) + cls.product_to_reconcile2 = cls.product_obj.create( + { + "name": "Purchased Product 2 (To reconcile)", + "type": "product", + "standard_price": 100.0, + "categ_id": cls.product_categ.id, + } + ) + # Create PO's: + cls.po = cls.po_obj.create( + { + "partner_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": cls.product_to_reconcile.id, + "name": cls.product_to_reconcile.name, + "product_qty": 5.0, + "price_unit": 100.0, + "product_uom": cls.product_to_reconcile.uom_id.id, + "date_planned": fields.Datetime.now(), + }, + ) + ], + } + ) + cls.po_2 = cls.po_obj.create( + { + "partner_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": cls.product_to_reconcile.id, + "name": cls.product_to_reconcile.name, + "product_qty": 5.0, + "price_unit": 100.0, + "product_uom": cls.product_to_reconcile.uom_id.id, + "date_planned": fields.Datetime.now(), + }, + ) + ], + } + ) + # company settings for automated valuation + cls.company.purchase_lock_auto_reconcile = True + cls.company.purchase_reconcile_account_id = cls.writeoff_acc + cls.company.purchase_reconcile_journal_id = cls.stock_journal + + @classmethod + def _create_account(cls, acc_type, name, code, company, reconcile=False): + """Create an account.""" + account = cls.acc_obj.create( + { + "name": name, + "code": code, + "account_type": acc_type, + "company_id": company.id, + "reconcile": reconcile, + } + ) + return account + + def _create_delivery( + self, + product, + qty, + ): + return self.env["stock.picking"].create( + { + "name": self.product_to_reconcile.name, + "partner_id": self.partner.id, + "picking_type_id": self.env.ref("stock.picking_type_out").id, + "location_id": self.env.ref("stock.stock_location_stock").id, + "location_dest_id": self.env.ref("stock.stock_location_customers").id, + "move_ids": [ + ( + 0, + 0, + { + "name": self.product_to_reconcile.name, + "product_id": self.product_to_reconcile.id, + "product_uom": self.product_to_reconcile.uom_id.id, + "product_uom_qty": qty, + "location_id": self.env.ref( + "stock.stock_location_stock" + ).id, + "location_dest_id": self.env.ref( + "stock.stock_location_customers" + ).id, + "procure_method": "make_to_stock", + }, + ) + ], + } + ) + + def _do_picking(self, picking, date): + """Do picking with only one move on the given date.""" + picking.action_confirm() + picking.action_assign() + for move in picking.move_ids: + move.quantity = move.product_uom_qty + move.date = date + picking.button_validate() + + def test_01_nothing_to_reconcile(self): + po = self.po + self.assertEqual(po.state, "draft") + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + self.assertTrue(po.unreconciled) + # Invoice created and validated: + po.action_create_invoice() + invoice_ids = po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice") + invoice_ids.invoice_date = datetime.now() + invoice_ids.action_post() + self.assertEqual(po.state, "purchase") + # odoo does it automatically + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + + def test_03_search_unreconciled(self): + """Test searching unreconciled PO's.""" + po = self.po_2 + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + res = self.po_obj.search([("unreconciled", "=", True)]) + po._compute_unreconciled() + self.assertIn(po, res) + self.assertNotIn(self.po, res) + # Test value error: + with self.assertRaises(ValueError): + self.po_obj.search([("unreconciled", "=", "true")]) + + def test_04_action_reconcile(self): + """Test reconcile.""" + # Invoice created and validated: + po = self.po_2 + self.assertTrue(po.unreconciled) + po.action_create_invoice() + invoice_form = Form( + po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice")[0] + ) + # v14 reconciles automatically so here we force discrepancy + # with invoice_form.edit(0) as inv_form: + invoice_form.invoice_date = datetime.now() + with invoice_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 99 + invoice = invoice_form.save() + invoice.action_post() + self.assertTrue(po.unreconciled) + po.action_reconcile() + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + + def test_05_button_done_reconcile(self): + """Test auto reconcile when locking po.""" + po = self.po_2.copy() + po.company_id.purchase_reconcile_account_id = self.writeoff_acc + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + # Invoice created and validated: + # Odoo reconciles automatically so here we force discrepancy + po.action_create_invoice() + invoice_form = Form( + po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice")[0] + ) + invoice_form.invoice_date = datetime.now() + with invoice_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 99 + invoice = invoice_form.save() + invoice.action_post() + self.assertTrue(po.unreconciled) + # check error if raised if not write-off account + with self.assertRaises(exceptions.ValidationError): + self.company.purchase_reconcile_account_id = False + po.button_done() + # restore the write off account + self.company.purchase_reconcile_account_id = self.writeoff_acc + po.button_done() + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + + def test_06_dropship_not_reconcile_sale_journal_items(self): + """ + Create a fake dropship and lock the PO before receiving the customer + invoice. The PO should not close the stock interim output account + """ + # to create the fake dropship we create a delivery and attach the + # journals to the purchase order craeted later + self.env["stock.quant"].create( + { + "product_id": self.product_to_reconcile.id, + "location_id": self.env.ref("stock.stock_location_stock").id, + "quantity": 1.0, + } + ) + delivery = self._create_delivery(self.product_to_reconcile, 1) + self._do_picking(delivery, fields.Datetime.now()) + # We create the PO now and receive it + po = self.po_2.copy() + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + self.assertTrue(po.unreconciled) + # as long stock_dropshipping is not dependency, I force the PO to be in + # the journal items of the delivery + delivery_name = delivery.name + delivery_ji = self.env["account.move.line"].search( + [("move_id.ref", "=", delivery_name)] + ) + delivery_ji.write( + {"purchase_line_id": po.order_line[0], "purchase_order_id": po.id} + ) + # then I lock the po to force reconciliation + po.button_done() + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + # the PO is reconciled and the stock interim deliverd account is not + # reconciled yet + for jii in delivery_ji: + self.assertFalse(jii.reconciled) + + def test_07_multicompany(self): + """ + Force the company in the vendor bill to be wrong. The system will + write-off the journals for the shipment because those are the only ones + with the correct company + """ + po = self.po.copy() + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + # Invoice created and validated: + f = Form(self.account_move_obj.with_context(default_move_type="in_invoice")) + f.partner_id = po.partner_id + f.invoice_date = fields.Date().today() + f.purchase_vendor_bill_id = self.env["purchase.bill.union"].browse(-po.id) + invoice = f.save() + chicago_journal = self.env["account.journal"].create( + { + "name": "chicago", + "code": "ref", + "type": "purchase", + "company_id": self.ref("stock.res_company_1"), + } + ) + invoice.write( + { + "name": "/", + } + ) + invoice.write( + { + "company_id": self.ref("stock.res_company_1"), + "journal_id": chicago_journal.id, + } + ) + invoice.action_post() + self.assertEqual(po.state, "purchase") + # The bill is wrong so this is unreconciled + self.assertTrue(po.unreconciled) + po.button_done() + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + # we check all the journals for the po have the same company + ji = self.env["account.move.line"].search( + [("purchase_order_id", "=", po.id), ("move_id", "!=", invoice.id)] + ) + self.assertEqual(po.company_id, ji.mapped("company_id")) + + def test_08_reconcile_by_product(self): + """ + Create a write-off by product + """ + po = self.po.copy() + po.write( + { + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_to_reconcile2.id, + "name": self.product_to_reconcile2.name, + "product_qty": 5.0, + "price_unit": 100.0, + "product_uom": self.product_to_reconcile.uom_id.id, + "date_planned": fields.Datetime.now(), + }, + ) + ], + } + ) + po.button_confirm() + self._do_picking(po.picking_ids, fields.Datetime.now()) + # Invoice created and validated: + f = Form(self.account_move_obj.with_context(default_move_type="in_invoice")) + f.partner_id = po.partner_id + f.invoice_date = fields.Date().today() + f.purchase_vendor_bill_id = self.env["purchase.bill.union"].browse(-po.id) + invoice = f.save() + # force discrepancies + with f.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 99 + with f.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 99 + invoice = f.save() + invoice._post() + # The bill is different price so this is unreconciled + po._compute_unreconciled() + self.assertTrue(po.unreconciled) + po.button_done() + po._compute_unreconciled() + self.assertFalse(po.unreconciled) + # we check all the journals are balanced by product + ji_p1 = self.env["account.move.line"].search( + [ + ("purchase_order_id", "=", po.id), + ("product_id", "=", self.product_to_reconcile.id), + ("account_id", "=", self.account_grni.id), + ] + ) + ji_p2 = self.env["account.move.line"].search( + [ + ("purchase_order_id", "=", po.id), + ("product_id", "=", self.product_to_reconcile2.id), + ("account_id", "=", self.account_grni.id), + ] + ) + self.assertEqual(sum(ji_p1.mapped("balance")), 0.0) + self.assertEqual(sum(ji_p2.mapped("balance")), 0.0) diff --git a/purchase_unreconciled/views/purchase_order_view.xml b/purchase_unreconciled/views/purchase_order_view.xml new file mode 100644 index 000000000..efb689062 --- /dev/null +++ b/purchase_unreconciled/views/purchase_order_view.xml @@ -0,0 +1,62 @@ + + + + purchase.order.form - purchase_unreconciled + purchase.order + + + + + +
+ + +
+
+
+ + Custom Purchase Unreconciled Search + purchase.order + + + + + + + + + + unreconciled.amount.purchase.order.tree + purchase.order + + + + + + + +
diff --git a/purchase_unreconciled/views/res_config_settings_view.xml b/purchase_unreconciled/views/res_config_settings_view.xml new file mode 100644 index 000000000..ab534659c --- /dev/null +++ b/purchase_unreconciled/views/res_config_settings_view.xml @@ -0,0 +1,30 @@ + + + + res.config.settings.view.form.purchase.unreconciled + res.config.settings + + + + + + + + + + + + + + + + + + + + + diff --git a/purchase_unreconciled/wizards/__init__.py b/purchase_unreconciled/wizards/__init__.py new file mode 100644 index 000000000..13608e076 --- /dev/null +++ b/purchase_unreconciled/wizards/__init__.py @@ -0,0 +1 @@ +from . import purchase_unreconciled_exceeded diff --git a/purchase_unreconciled/wizards/purchase_unreconciled_exceeded.py b/purchase_unreconciled/wizards/purchase_unreconciled_exceeded.py new file mode 100644 index 000000000..28dd69820 --- /dev/null +++ b/purchase_unreconciled/wizards/purchase_unreconciled_exceeded.py @@ -0,0 +1,36 @@ +from odoo import _, fields, models + + +class PurchaseUnreconciledExceededWiz(models.TransientModel): + _name = "purchase.unreconciled.exceeded.wiz" + _description = "Purchase Unreconciled Exceeded Wizard" + + purchase_id = fields.Many2one( + comodel_name="purchase.order", readonly=True, string="Order Number" + ) + exception_msg = fields.Text(readonly=True) + origin_reference = fields.Reference( + lambda self: [ + (m.model, m.name) for m in self.env["ir.model"].sudo().search([]) + ], + string="Object", + ) + continue_method = fields.Char() + + def action_show(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Purchase unreconciled exceeded"), + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + def button_continue(self): + self.ensure_one() + return getattr( + self.origin_reference.with_context(bypass_unreconciled=True), + self.continue_method, + )() diff --git a/purchase_unreconciled/wizards/purchase_unreconciled_exceeded_view.xml b/purchase_unreconciled/wizards/purchase_unreconciled_exceeded_view.xml new file mode 100644 index 000000000..5bc314177 --- /dev/null +++ b/purchase_unreconciled/wizards/purchase_unreconciled_exceeded_view.xml @@ -0,0 +1,26 @@ + + + + purchase unreconciled exceeded + purchase.unreconciled.exceeded.wiz + +
+

The order has exceeded its amount unreconciled

+ + + + + + +
+
+