From a571f061ee0a4ee4b3a8fdc3a95f01392df18e0b Mon Sep 17 00:00:00 2001 From: AaronHForgeFlow Date: Tue, 23 Nov 2021 17:08:24 +0100 Subject: [PATCH 1/6] [ADD] purchase_unreconciled --- purchase_unreconciled/README.rst | 93 ++++ purchase_unreconciled/__init__.py | 1 + purchase_unreconciled/__manifest__.py | 17 + purchase_unreconciled/models/__init__.py | 4 + .../models/account_move_line.py | 72 +++ purchase_unreconciled/models/company.py | 20 + .../models/purchase_order.py | 157 +++++++ .../models/res_config_settings.py | 16 + purchase_unreconciled/readme/CONTRIBUTORS.rst | 7 + purchase_unreconciled/readme/DESCRIPTION.rst | 5 + purchase_unreconciled/readme/USAGE.rst | 6 + .../static/description/index.html | 436 ++++++++++++++++++ purchase_unreconciled/tests/__init__.py | 1 + .../tests/test_purchase_unreconciled.py | 345 ++++++++++++++ .../views/purchase_order_view.xml | 58 +++ .../views/res_config_settings_view.xml | 40 ++ 16 files changed, 1278 insertions(+) create mode 100644 purchase_unreconciled/README.rst create mode 100644 purchase_unreconciled/__init__.py create mode 100644 purchase_unreconciled/__manifest__.py create mode 100644 purchase_unreconciled/models/__init__.py create mode 100644 purchase_unreconciled/models/account_move_line.py create mode 100644 purchase_unreconciled/models/company.py create mode 100644 purchase_unreconciled/models/purchase_order.py create mode 100644 purchase_unreconciled/models/res_config_settings.py create mode 100644 purchase_unreconciled/readme/CONTRIBUTORS.rst create mode 100644 purchase_unreconciled/readme/DESCRIPTION.rst create mode 100644 purchase_unreconciled/readme/USAGE.rst create mode 100644 purchase_unreconciled/static/description/index.html create mode 100644 purchase_unreconciled/tests/__init__.py create mode 100644 purchase_unreconciled/tests/test_purchase_unreconciled.py create mode 100644 purchase_unreconciled/views/purchase_order_view.xml create mode 100644 purchase_unreconciled/views/res_config_settings_view.xml diff --git a/purchase_unreconciled/README.rst b/purchase_unreconciled/README.rst new file mode 100644 index 000000000..937df672d --- /dev/null +++ b/purchase_unreconciled/README.rst @@ -0,0 +1,93 @@ +===================== +Purchase Unreconciled +===================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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/14.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-14-0/account-financial-tools-14-0-purchase_unreconciled + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/92/14.0 + :alt: Try me on Runbot + +|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. + +**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 smashing 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. + +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..0650744f6 --- /dev/null +++ b/purchase_unreconciled/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/purchase_unreconciled/__manifest__.py b/purchase_unreconciled/__manifest__.py new file mode 100644 index 000000000..0cf6daddd --- /dev/null +++ b/purchase_unreconciled/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2019 ForgeFlow S.L. +# - Lois Rilo Antelo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Purchase Unreconciled", + "version": "14.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": ["views/purchase_order_view.xml", "views/res_config_settings_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..33b2afe79 --- /dev/null +++ b/purchase_unreconciled/models/account_move_line.py @@ -0,0 +1,72 @@ +# Copyright 2019 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 + ) + + 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() + partners = self.mapped("partner_id") + 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"], + "purchase_order_id": writeoff_vals["purchase_order_id"], + "journal_id": writeoff_vals["journal_id"], + "currency_id": writeoff_vals["currency_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": datetime.now(), + "journal_id": writeoff_vals["journal_id"], + "currency_id": writeoff_vals["currency_id"], + "line_ids": [(0, 0, write_off_vals), (0, 0, counter_part)], + } + ) + move.action_post() + return move.line_ids.filtered( + lambda l: l.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..68ce46d51 --- /dev/null +++ b/purchase_unreconciled/models/company.py @@ -0,0 +1,20 @@ +# Copyright 2019 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" + ) diff --git a/purchase_unreconciled/models/purchase_order.py b/purchase_unreconciled/models/purchase_order.py new file mode 100644 index 000000000..dce72356e --- /dev/null +++ b/purchase_unreconciled/models/purchase_order.py @@ -0,0 +1,157 @@ +# Copyright 2019 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 + + +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", + ) + is_shipped = fields.Boolean(search="_search_is_shipped") + + @api.model + def _get_purchase_unreconciled_base_domain(self): + included_accounts = ( + ( + self.env["product.category"].search( + [("property_valuation", "=", "real_time")] + ) + ) + .mapped("property_stock_account_input_categ_id") + .ids + ) + unreconciled_domain = [ + ("account_id.reconcile", "=", True), + ("account_id", "in", included_accounts), + ("move_id.state", "=", "posted"), + # for some reason when amount_residual is zero + # is marked as reconciled, this is better check + ("full_reconcile_id", "=", False), + ("company_id", "in", self.env.companies.ids), + ] + return unreconciled_domain + + def _search_is_shipped(self, operator, value): + if operator != "=" or not isinstance(value, bool): + raise ValueError(_("Unsupported search operator")) + is_shipped_pos = self.search([("picking_ids.state", "in", ("done", "cancel"))]) + if value: + return [("id", "in", is_shipped_pos.ids)] + else: + return [("id", "not in", is_shipped_pos.ids)] + + def _compute_unreconciled(self): + acc_item = self.env["account.move.line"] + for rec in self: + domain = rec._get_purchase_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("purchase_order_id", "=", rec.id)]] + ) + unreconciled_items = acc_item.search(unreconciled_domain) + rec.unreconciled = len(unreconciled_items) > 0 + + def _search_unreconciled(self, operator, value): + if operator != "=" or not isinstance(value, bool): + raise ValueError(_("Unsupported search operator")) + acc_item = self.env["account.move.line"] + domain = self._get_purchase_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("purchase_order_id", "!=", False)]] + ) + unreconciled_items = acc_item.search(unreconciled_domain) + unreconciled_pos = unreconciled_items.mapped("purchase_order_id") + 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._get_purchase_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("purchase_order_id", "=", self.id)]] + ) + 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 jounral for purchases is missing. An " + "accountant must fill that information" + ) + ) + self.ensure_one() + domain = self._get_purchase_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("purchase_order_id", "=", self.id)]] + ) + unreconciled_domain = expression.AND( + [unreconciled_domain, [("company_id", "=", self.company_id.id)]] + ) + unreconciled_items = self.env["account.move.line"].search(unreconciled_domain) + writeoff_to_reconcile = False + for account in unreconciled_items.mapped("account_id"): + acc_unrec_items = unreconciled_items.filtered( + lambda ml: ml.account_id == account + ) + all_aml_share_same_currency = all( + [x.currency_id == self[0].currency_id for x in acc_unrec_items] + ) + writeoff_vals = { + "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, + "currency_id": self.currency_id.id, + } + if not all_aml_share_same_currency: + writeoff_vals["amount_currency"] = False + if writeoff_to_reconcile: + writeoff_to_reconcile += unreconciled_items._create_writeoff( + writeoff_vals + ) + else: + writeoff_to_reconcile = unreconciled_items._create_writeoff( + writeoff_vals + ) + # add writeoff line to reconcile algorithm and finish the reconciliation + if writeoff_to_reconcile: + remaining_moves = unreconciled_items + writeoff_to_reconcile + else: + remaining_moves = unreconciled_items + # Check if reconciliation is total or needs an exchange rate entry to be created + if remaining_moves: + remaining_moves.filtered(lambda l: not l.reconciled).reconcile() + return { + "name": _("Reconciled journal items"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "tree,form", + "res_model": "account.move.line", + "domain": [ + ("id", "in", unreconciled_items.ids + writeoff_to_reconcile.ids) + ], + } + + def button_done(self): + for rec in self: + if rec.unreconciled: + rec.action_reconcile() + return super(PurchaseOrder, self).button_done() diff --git a/purchase_unreconciled/models/res_config_settings.py b/purchase_unreconciled/models/res_config_settings.py new file mode 100644 index 000000000..4a84f403f --- /dev/null +++ b/purchase_unreconciled/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2016 Akretion (Alexis de Lattre ) +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://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 + ) diff --git a/purchase_unreconciled/readme/CONTRIBUTORS.rst b/purchase_unreconciled/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..c4de5c908 --- /dev/null +++ b/purchase_unreconciled/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* ForgeFlow S.L. + + - Lois Rilo + + - Aaron Henriquez + + - Miquel Raich diff --git a/purchase_unreconciled/readme/DESCRIPTION.rst b/purchase_unreconciled/readme/DESCRIPTION.rst new file mode 100644 index 000000000..d34220243 --- /dev/null +++ b/purchase_unreconciled/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +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.rst b/purchase_unreconciled/readme/USAGE.rst new file mode 100644 index 000000000..550778548 --- /dev/null +++ b/purchase_unreconciled/readme/USAGE.rst @@ -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/static/description/index.html b/purchase_unreconciled/static/description/index.html new file mode 100644 index 000000000..f37fc7e01 --- /dev/null +++ b/purchase_unreconciled/static/description/index.html @@ -0,0 +1,436 @@ + + + + + + +Purchase Unreconciled + + + +
+

Purchase Unreconciled

+ + +

Beta License: AGPL-3 OCA/account-financial-tools Translate me on Weblate Try me on Runbot

+

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.

+

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 smashing 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.

+

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..a50997522 --- /dev/null +++ b/purchase_unreconciled/tests/test_purchase_unreconciled.py @@ -0,0 +1,345 @@ +# Copyright 2019 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.invoice_obj = cls.env["account.move"] + cls.company = cls.env.ref("base.main_company") + cls.company.anglo_saxon_accounting = True + assets = cls.env.ref("account.data_account_type_current_assets") + expenses = cls.env.ref("account.data_account_type_expenses") + equity = cls.env.ref("account.data_account_type_equity") + # 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, + "user_type_id": assets.id, + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.writeoff_acc = cls.acc_obj.create( + { + "name": "Write-offf account", + "code": 8888, + "user_type_id": expenses.id, + "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 + 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 = expenses + 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 = expenses + cls.account_gdni = cls._create_account( + acc_type, name, code, cls.company, reconcile=True + ) + # Create account for Inventory + name = "Inventory" + code = "inventory" + acc_type = assets + 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, + } + ) + + # 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_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, + "user_type_id": acc_type.id, + "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_lines": [ + ( + 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.move_lines.quantity_done = picking.move_lines.product_uom_qty + picking._action_done() + for move in picking.move_lines: + move.date = date + + 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() + po.invoice_ids.invoice_date = datetime.now() + po.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[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[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: + move_form = Form(self.invoice_obj.with_context(default_type="in_invoice")) + move_form.partner_id = self.partner + move_form.purchase_id = po + invoice = move_form.save() + chicago_journal = self.env["account.journal"].create( + { + "name": "chicago", + "code": "ref", + "type": "sale", + "company_id": self.ref("stock.res_company_1"), + } + ) + 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")) diff --git a/purchase_unreconciled/views/purchase_order_view.xml b/purchase_unreconciled/views/purchase_order_view.xml new file mode 100644 index 000000000..2f55a0cef --- /dev/null +++ b/purchase_unreconciled/views/purchase_order_view.xml @@ -0,0 +1,58 @@ + + + + purchase.order.form - purchase_unreconciled + purchase.order + + + + + +
+ + +
+
+
+ + Custom Purchase Unreconciled Search + 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..b01454e02 --- /dev/null +++ b/purchase_unreconciled/views/res_config_settings_view.xml @@ -0,0 +1,40 @@ + + + + res.config.settings.view.form.purchase.unreconciled + res.config.settings + + + +

Purchase Reconciling

+
+
+
+
+
+
+
+ + + + From 9b4a553d10656ee7a89bdb3daaff0c7c3961b1b7 Mon Sep 17 00:00:00 2001 From: AaronHForgeFlow Date: Wed, 29 Dec 2021 11:41:34 +0100 Subject: [PATCH 2/6] [IMP] purchase_unreconciled: black, isort, prettier --- purchase_unreconciled/models/account_move_line.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/purchase_unreconciled/models/account_move_line.py b/purchase_unreconciled/models/account_move_line.py index 33b2afe79..b97afd521 100644 --- a/purchase_unreconciled/models/account_move_line.py +++ b/purchase_unreconciled/models/account_move_line.py @@ -13,10 +13,10 @@ class AccountMoveLine(models.Model): 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 + sum(line["amount_residual"] for line in self), precision ) writeoff_amount_curr = round( - sum([line["amount_residual_currency"] for line in self]), precision + sum(line["amount_residual_currency"] for line in self), precision ) first_currency = self[0]["currency_id"] From ac27f097321c4976f4bdd28be66055b685df7736 Mon Sep 17 00:00:00 2001 From: AaronHForgeFlow Date: Wed, 29 Dec 2021 13:34:20 +0100 Subject: [PATCH 3/6] [15.0][MIG]purchase_unreconciled --- purchase_unreconciled/README.rst | 40 ++- purchase_unreconciled/__init__.py | 1 + purchase_unreconciled/__manifest__.py | 12 +- .../models/account_move_line.py | 32 ++- purchase_unreconciled/models/company.py | 9 +- .../models/purchase_order.py | 238 +++++++++++++----- .../models/res_config_settings.py | 11 +- .../security/ir.model.access.csv | 2 + .../static/description/index.html | 50 ++-- .../tests/test_purchase_unreconciled.py | 93 ++++++- .../views/purchase_order_view.xml | 18 +- .../views/res_config_settings_view.xml | 30 ++- purchase_unreconciled/wizards/__init__.py | 1 + .../wizards/purchase_unreconciled_exceeded.py | 36 +++ .../purchase_unreconciled_exceeded_view.xml | 26 ++ 15 files changed, 477 insertions(+), 122 deletions(-) create mode 100644 purchase_unreconciled/security/ir.model.access.csv create mode 100644 purchase_unreconciled/wizards/__init__.py create mode 100644 purchase_unreconciled/wizards/purchase_unreconciled_exceeded.py create mode 100644 purchase_unreconciled/wizards/purchase_unreconciled_exceeded_view.xml diff --git a/purchase_unreconciled/README.rst b/purchase_unreconciled/README.rst index 937df672d..b78b2c2b1 100644 --- a/purchase_unreconciled/README.rst +++ b/purchase_unreconciled/README.rst @@ -2,28 +2,31 @@ 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-Beta-yellow.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status - :alt: Beta + :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/14.0/purchase_unreconciled + :target: https://github.com/OCA/account-financial-tools/tree/15.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-14-0/account-financial-tools-14-0-purchase_unreconciled + :target: https://translation.odoo-community.org/projects/account-financial-tools-15-0/account-financial-tools-15-0-purchase_unreconciled :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/92/14.0 - :alt: Try me on Runbot +.. |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=15.0 + :alt: Try me on Runboat -|badge1| |badge2| |badge3| |badge4| |badge5| +|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. @@ -31,6 +34,11 @@ 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:: @@ -51,8 +59,8 @@ 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 smashing it by providing a detailed and welcomed -`feedback `_. +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. @@ -88,6 +96,14 @@ 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. -This module is part of the `OCA/account-financial-tools `_ project on GitHub. +.. |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 index 0650744f6..aee8895e7 100644 --- a/purchase_unreconciled/__init__.py +++ b/purchase_unreconciled/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizards diff --git a/purchase_unreconciled/__manifest__.py b/purchase_unreconciled/__manifest__.py index 0cf6daddd..ec61390c2 100644 --- a/purchase_unreconciled/__manifest__.py +++ b/purchase_unreconciled/__manifest__.py @@ -1,15 +1,19 @@ -# Copyright 2019 ForgeFlow S.L. -# - Lois Rilo Antelo +# Copyright 2019-22 ForgeFlow S.L. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Purchase Unreconciled", - "version": "14.0.1.0.0", + "version": "15.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": ["views/purchase_order_view.xml", "views/res_config_settings_view.xml"], + "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", diff --git a/purchase_unreconciled/models/account_move_line.py b/purchase_unreconciled/models/account_move_line.py index b97afd521..3c67f3af8 100644 --- a/purchase_unreconciled/models/account_move_line.py +++ b/purchase_unreconciled/models/account_move_line.py @@ -1,4 +1,4 @@ -# Copyright 2019 ForgeFlow S.L. +# Copyright 2019-21 ForgeFlow S.L. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from datetime import datetime @@ -18,7 +18,9 @@ class AccountMoveLine(models.Model): 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 @@ -37,7 +39,10 @@ class AccountMoveLine(models.Model): 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, @@ -45,13 +50,16 @@ class AccountMoveLine(models.Model): "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"], - "purchase_order_id": writeoff_vals["purchase_order_id"], + "date": move_date, "journal_id": writeoff_vals["journal_id"], - "currency_id": writeoff_vals["currency_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")) + 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"] @@ -60,12 +68,22 @@ class AccountMoveLine(models.Model): move = self.env["account.move"].create( { - "date": datetime.now(), + "date": move_date, "journal_id": writeoff_vals["journal_id"], - "currency_id": writeoff_vals["currency_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 l: l.account_id.id == counterpart_account.id diff --git a/purchase_unreconciled/models/company.py b/purchase_unreconciled/models/company.py index 68ce46d51..3fb456129 100644 --- a/purchase_unreconciled/models/company.py +++ b/purchase_unreconciled/models/company.py @@ -1,4 +1,4 @@ -# Copyright 2019 ForgeFlow S.L. +# Copyright 2019-21 ForgeFlow S.L. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from odoo import fields, models @@ -18,3 +18,10 @@ class ResCompany(models.Model): 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 index dce72356e..7d0a85dca 100644 --- a/purchase_unreconciled/models/purchase_order.py +++ b/purchase_unreconciled/models/purchase_order.py @@ -1,8 +1,9 @@ -# Copyright 2019 ForgeFlow S.L. +# 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): @@ -16,57 +17,60 @@ class PurchaseOrder(models.Model): "everything is reconciled or that the related accounts do not " "allow reconciliation", ) - is_shipped = fields.Boolean(search="_search_is_shipped") + amount_unreconciled = fields.Float(compute="_compute_unreconciled") - @api.model - def _get_purchase_unreconciled_base_domain(self): + def _get_account_domain(self): + self.ensure_one() included_accounts = ( ( - self.env["product.category"].search( - [("property_valuation", "=", "real_time")] - ) + 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), - ("account_id", "in", included_accounts), + ("account_id.internal_type", "not in", ["receivable", "payable"]), ("move_id.state", "=", "posted"), - # for some reason when amount_residual is zero - # is marked as reconciled, this is better check - ("full_reconcile_id", "=", False), ("company_id", "in", self.env.companies.ids), + # same condition than Odoo Unreconciled filter + ("full_reconcile_id", "=", False), + ("balance", "!=", 0.0), ] return unreconciled_domain - def _search_is_shipped(self, operator, value): - if operator != "=" or not isinstance(value, bool): - raise ValueError(_("Unsupported search operator")) - is_shipped_pos = self.search([("picking_ids.state", "in", ("done", "cancel"))]) - if value: - return [("id", "in", is_shipped_pos.ids)] - else: - return [("id", "not in", is_shipped_pos.ids)] - def _compute_unreconciled(self): acc_item = self.env["account.move.line"] for rec in self: - domain = rec._get_purchase_unreconciled_base_domain() + 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( - [domain, [("purchase_order_id", "=", rec.id)]] + [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 != "=" or not isinstance(value, bool): + 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() unreconciled_domain = expression.AND( [domain, [("purchase_order_id", "!=", False)]] ) + unreconciled_domain = expression.AND( + [unreconciled_domain, [("company_id", "in", self.env.companies.ids)]] + ) unreconciled_items = acc_item.search(unreconciled_domain) unreconciled_pos = unreconciled_items.mapped("purchase_order_id") if value: @@ -77,10 +81,16 @@ class PurchaseOrder(models.Model): def action_view_unreconciled(self): self.ensure_one() acc_item = self.env["account.move.line"] - domain = self._get_purchase_unreconciled_base_domain() + 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( - [domain, [("purchase_order_id", "=", self.id)]] + [unreconciled_domain, [("purchase_order_id", "=", self.id)]] ) + unreconciled_domain.remove(("full_reconcile_id", "=", False)) + unreconciled_domain.remove("&") unreconciled_items = acc_item.search(unreconciled_domain) action = self.env.ref("account.action_account_moves_all") action_dict = action.read()[0] @@ -99,59 +109,169 @@ class PurchaseOrder(models.Model): ) ) 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) - writeoff_to_reconcile = False - for account in unreconciled_items.mapped("account_id"): - acc_unrec_items = unreconciled_items.filtered( - lambda ml: ml.account_id == account + 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 ) - all_aml_share_same_currency = all( - [x.currency_id == self[0].currency_id for x in acc_unrec_items] - ) - writeoff_vals = { - "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, - "currency_id": self.currency_id.id, - } - if not all_aml_share_same_currency: - writeoff_vals["amount_currency"] = False - if writeoff_to_reconcile: - writeoff_to_reconcile += unreconciled_items._create_writeoff( - writeoff_vals + unreconciled_items_group = unreconciled_items.filtered( + lambda l: ( + l.account_id.id == account_id and l.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 l: l.amount_residual_currency != 0.0 + and l.account_id.id == account_id + ) + ) + if amount_residual_currency_reconcile: + residual_field = "amount_residual_currency" else: - writeoff_to_reconcile = unreconciled_items._create_writeoff( + 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 ) - # add writeoff line to reconcile algorithm and finish the reconciliation - if writeoff_to_reconcile: - remaining_moves = unreconciled_items + writeoff_to_reconcile - else: - remaining_moves = unreconciled_items - # Check if reconciliation is total or needs an exchange rate entry to be created - if remaining_moves: - remaining_moves.filtered(lambda l: not l.reconciled).reconcile() - return { + 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 l: not l.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", unreconciled_items.ids + writeoff_to_reconcile.ids) - ], + "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: - if rec.unreconciled: - rec.action_reconcile() - return super(PurchaseOrder, self).button_done() + 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 index 4a84f403f..afce10572 100644 --- a/purchase_unreconciled/models/res_config_settings.py +++ b/purchase_unreconciled/models/res_config_settings.py @@ -1,6 +1,5 @@ -# Copyright 2016 Akretion (Alexis de Lattre ) -# Copyright 2018 Tecnativa - Pedro M. Baeza -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# Copyright 2019-21 ForgeFlow S.L.. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from odoo import fields, models @@ -14,3 +13,9 @@ class ResConfigSettings(models.TransientModel): 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/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 index f37fc7e01..2499658cb 100644 --- a/purchase_unreconciled/static/description/index.html +++ b/purchase_unreconciled/static/description/index.html @@ -1,20 +1,20 @@ - + - + Purchase Unreconciled