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

+
+
+
+
+
+
+
+ + + +