From 2971dcf53e80b57013a19074685bf06afaae81b2 Mon Sep 17 00:00:00 2001 From: AaronHForgeFlow Date: Sat, 27 Nov 2021 11:58:37 +0100 Subject: [PATCH] [14.0][ADD] sale_unreconciled --- sale_unreconciled/README.rst | 102 ++++ sale_unreconciled/__init__.py | 1 + sale_unreconciled/__manifest__.py | 16 + sale_unreconciled/models/__init__.py | 4 + sale_unreconciled/models/account_move_line.py | 72 +++ sale_unreconciled/models/company.py | 19 + .../models/res_config_settings.py | 15 + sale_unreconciled/models/sale_order.py | 133 +++++ sale_unreconciled/readme/CONTRIBUTORS.rst | 3 + sale_unreconciled/readme/DESCRIPTION.rst | 5 + sale_unreconciled/readme/USAGE.rst | 6 + .../static/description/index.html | 442 ++++++++++++++++ sale_unreconciled/tests/__init__.py | 1 + .../tests/test_sale_unreconciled.py | 492 ++++++++++++++++++ .../views/res_config_settings_view.xml | 37 ++ sale_unreconciled/views/sale_order_view.xml | 52 ++ .../odoo/addons/sale_unreconciled | 1 + setup/sale_unreconciled/setup.py | 6 + 18 files changed, 1407 insertions(+) create mode 100644 sale_unreconciled/README.rst create mode 100644 sale_unreconciled/__init__.py create mode 100644 sale_unreconciled/__manifest__.py create mode 100644 sale_unreconciled/models/__init__.py create mode 100644 sale_unreconciled/models/account_move_line.py create mode 100644 sale_unreconciled/models/company.py create mode 100644 sale_unreconciled/models/res_config_settings.py create mode 100644 sale_unreconciled/models/sale_order.py create mode 100644 sale_unreconciled/readme/CONTRIBUTORS.rst create mode 100644 sale_unreconciled/readme/DESCRIPTION.rst create mode 100644 sale_unreconciled/readme/USAGE.rst create mode 100644 sale_unreconciled/static/description/index.html create mode 100644 sale_unreconciled/tests/__init__.py create mode 100644 sale_unreconciled/tests/test_sale_unreconciled.py create mode 100644 sale_unreconciled/views/res_config_settings_view.xml create mode 100644 sale_unreconciled/views/sale_order_view.xml create mode 120000 setup/sale_unreconciled/odoo/addons/sale_unreconciled create mode 100644 setup/sale_unreconciled/setup.py diff --git a/sale_unreconciled/README.rst b/sale_unreconciled/README.rst new file mode 100644 index 000000000000..88c2af56b403 --- /dev/null +++ b/sale_unreconciled/README.rst @@ -0,0 +1,102 @@ +================= +Sale Unreconciled +================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/14.0/sale_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-sale_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 Sales Orders, that allows +to find SO's with unreconciled journal items related. + +This module allows to reconcile those SO 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 Sale Orders that shows +outstanding balances in interim accounts. Also there is a link in the SO +to those outstanding journal items. + +Locking the SO 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. + + - Aaron Henriquez + +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/sale_unreconciled/__init__.py b/sale_unreconciled/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/sale_unreconciled/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_unreconciled/__manifest__.py b/sale_unreconciled/__manifest__.py new file mode 100644 index 000000000000..1352535734d3 --- /dev/null +++ b/sale_unreconciled/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2021 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Sale Unreconciled", + "version": "14.0.1.0.0", + "author": "ForgeFlow S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-financial-tools", + "category": "Accounting", + "depends": ["sale_mrp", "account_move_line_sale_info"], + "data": ["views/sale_order_view.xml", "views/res_config_settings_view.xml"], + "license": "AGPL-3", + "installable": True, + "development_status": "Alpha", + "maintainers": ["AaronHForgeFlow"], +} diff --git a/sale_unreconciled/models/__init__.py b/sale_unreconciled/models/__init__.py new file mode 100644 index 000000000000..c8cb68bba902 --- /dev/null +++ b/sale_unreconciled/models/__init__.py @@ -0,0 +1,4 @@ +from . import sale_order +from . import company +from . import res_config_settings +from . import account_move_line diff --git a/sale_unreconciled/models/account_move_line.py b/sale_unreconciled/models/account_move_line.py new file mode 100644 index 000000000000..c7f54264f9f3 --- /dev/null +++ b/sale_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_so_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_so_writeoff(self, writeoff_vals): + ( + amount_writeoff, + amount_writeoff_curr, + same_curr, + ) = self._get_so_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"], + "sale_order_id": writeoff_vals["sale_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/sale_unreconciled/models/company.py b/sale_unreconciled/models/company.py new file mode 100644 index 000000000000..c59996d3188a --- /dev/null +++ b/sale_unreconciled/models/company.py @@ -0,0 +1,19 @@ +# Copyright 2021 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" + + sale_reconcile_account_id = fields.Many2one( + "account.account", + domain=lambda self: [("deprecated", "=", False)], + string="Write-Off Account On Sales", + ondelete="restrict", + copy=False, + help="Write-off account to reconcile Unreconciled Sale Orders", + ) + + sale_reconcile_journal_id = fields.Many2one("account.journal") diff --git a/sale_unreconciled/models/res_config_settings.py b/sale_unreconciled/models/res_config_settings.py new file mode 100644 index 000000000000..83a9b6319bfb --- /dev/null +++ b/sale_unreconciled/models/res_config_settings.py @@ -0,0 +1,15 @@ +# Copyright 2021 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" + + sale_reconcile_account_id = fields.Many2one( + related="company_id.sale_reconcile_account_id", readonly=False + ) + sale_reconcile_journal_id = fields.Many2one( + related="company_id.sale_reconcile_journal_id", readonly=False + ) diff --git a/sale_unreconciled/models/sale_order.py b/sale_unreconciled/models/sale_order.py new file mode 100644 index 000000000000..8d82258f68a0 --- /dev/null +++ b/sale_unreconciled/models/sale_order.py @@ -0,0 +1,133 @@ +# Copyright 2021 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _, api, fields, models +from odoo.osv import expression + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + unreconciled = fields.Boolean( + compute="_compute_unreconciled", + search="_search_unreconciled", + help="""Indicates that a sale Order has related Journal items not + reconciled.Note that if it is false it can be either that + everything is reconciled or that the related accounts do not + allow reconciliation""", + ) + + @api.model + def _get_sale_unreconciled_base_domain(self): + included_accounts = ( + ( + self.env["product.category"].search( + [("property_valuation", "=", "real_time")] + ) + ) + .mapped("property_stock_account_output_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 _compute_unreconciled(self): + acc_item = self.env["account.move.line"] + for rec in self: + domain = rec._get_sale_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("sale_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_sale_unreconciled_base_domain() + unreconciled_domain = expression.AND([domain, [("sale_order_id", "!=", False)]]) + unreconciled_items = acc_item.search(unreconciled_domain) + unreconciled_sos = unreconciled_items.mapped("sale_order_id") + if value: + return [("id", "in", unreconciled_sos.ids)] + else: + return [("id", "not in", unreconciled_sos.ids)] + + def action_view_unreconciled(self): + self.ensure_one() + acc_item = self.env["account.move.line"] + domain = self._get_sale_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("sale_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): + self.ensure_one() + acc_item = self.env["account.move.line"] + domain = self._get_sale_unreconciled_base_domain() + unreconciled_domain = expression.AND( + [domain, [("sale_order_id", "=", self.id)]] + ) + unreconciled_items = acc_item.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.sale_reconcile_account_id.id, + "journal_id": self.company_id.sale_reconcile_journal_id.id, + "sale_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_so_writeoff( + writeoff_vals + ) + else: + writeoff_to_reconcile = unreconciled_items._create_so_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 action_done(self): + for rec in self: + if rec.unreconciled: + rec.action_reconcile() + return super(SaleOrder, self).action_done() diff --git a/sale_unreconciled/readme/CONTRIBUTORS.rst b/sale_unreconciled/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..99252f0248eb --- /dev/null +++ b/sale_unreconciled/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* ForgeFlow S.L. + + - Aaron Henriquez diff --git a/sale_unreconciled/readme/DESCRIPTION.rst b/sale_unreconciled/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..6c784698d03c --- /dev/null +++ b/sale_unreconciled/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module adds a new fields "Unreconciled" on Sales Orders, that allows +to find SO's with unreconciled journal items related. + +This module allows to reconcile those SO in a single click. In accounting +settings users will be able to set up a specific account for write-off. diff --git a/sale_unreconciled/readme/USAGE.rst b/sale_unreconciled/readme/USAGE.rst new file mode 100644 index 000000000000..0a044011a6f5 --- /dev/null +++ b/sale_unreconciled/readme/USAGE.rst @@ -0,0 +1,6 @@ +Accountants will be able to find a filters in Sale Orders that shows +outstanding balances in interim accounts. Also there is a link in the SO +to those outstanding journal items. + +Locking the SO will automatically reconcile the outstanding balance for the +stock iterim accounts. diff --git a/sale_unreconciled/static/description/index.html b/sale_unreconciled/static/description/index.html new file mode 100644 index 000000000000..7fe27341a095 --- /dev/null +++ b/sale_unreconciled/static/description/index.html @@ -0,0 +1,442 @@ + + + + + + +Sale Unreconciled + + + +
+

Sale Unreconciled

+ + +

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

+

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

+

This module allows to reconcile those SO 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 Sale Orders that shows +outstanding balances in interim accounts. Also there is a link in the SO +to those outstanding journal items.

+

Locking the SO 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.

+

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/sale_unreconciled/tests/__init__.py b/sale_unreconciled/tests/__init__.py new file mode 100644 index 000000000000..94172477df86 --- /dev/null +++ b/sale_unreconciled/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_unreconciled diff --git a/sale_unreconciled/tests/test_sale_unreconciled.py b/sale_unreconciled/tests/test_sale_unreconciled.py new file mode 100644 index 000000000000..6c384e2eefc0 --- /dev/null +++ b/sale_unreconciled/tests/test_sale_unreconciled.py @@ -0,0 +1,492 @@ +# Copyright 2021 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.tests import common + + +class TestsaleUnreconciled(common.SingleTransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.so_obj = cls.env["sale.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.categ_unit = cls.env.ref("uom.product_uom_categ_unit") + 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") + revenue = cls.env.ref("account.data_account_type_other_income") + cls.company = cls.env.ref("base.main_company") + + # Create partner: + cls.partner = cls.partner_obj.create({"name": "Test Vendor"}) + # Create standard product: + cls.product = cls.product_obj.create( + {"name": "saled Product", "type": "product"} + ) + # Create product that uses a reconcilable stock input account. + cls.stock_journal = cls.env["account.journal"].create( + {"name": "Stock Journal", "code": "STJTEST", "type": "general"} + ) + cls.sale_journal = cls.env["account.journal"].create( + {"name": "Sales Journal", "code": "SLTEST", "type": "sale"} + ) + # 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.writeoff_acc = cls.acc_obj.create( + { + "name": "Write-offf account", + "code": 8888, + "user_type_id": expenses.id, + "reconcile": True, + } + ) + 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": "saled Product (To reconcile)", + "type": "product", + "standard_price": 100, + "valuation": "real_time", + "categ_id": cls.product_categ.id, + } + ) + cls.account_revenue2 = cls.acc_obj.create( + { + "name": "Test revenue account 2", + "code": 1017, + "user_type_id": revenue.id, + "reconcile": False, + "company_id": cls.company.id, + } + ) + cls.account_expense2 = cls.acc_obj.create( + { + "name": "Dummy acccount", + "code": 7991, + "user_type_id": expenses.id, + "reconcile": False, + "company_id": cls.company.id, + } + ) + # company settings for automated valuation + cls.company.sale_reconcile_account_id = cls.writeoff_acc + cls.company.sale_reconcile_journal_id = cls.sale_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_incoming( + 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_in").id, + "location_dest_id": self.env.ref("stock.stock_location_stock").id, + "location_id": self.env.ref("stock.stock_location_suppliers").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_dest_id": self.env.ref( + "stock.stock_location_stock" + ).id, + "location_id": self.env.ref( + "stock.stock_location_suppliers" + ).id, + "procure_method": "make_to_stock", + }, + ) + ], + } + ) + + def _create_sale(self, line_products): + """Create a sale order. + + ``line_products`` is a list of tuple [(product, qty)] + """ + lines = [] + for product, qty in line_products: + line_values = { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "price_unit": 500, + } + lines.append((0, 0, line_values)) + return self.so_obj.create({"partner_id": self.partner.id, "order_line": lines}) + + def _do_picking(self, picking, date): + """Do picking with only one move on the given date.""" + picking.action_confirm() + for ml in picking.move_lines: + ml.quantity_done = ml.product_uom_qty + ml.date = date + picking._action_done() + + def test_01_nothing_to_reconcile(self): + """Test nothing is reconciled if no manual action""" + so = self._create_sale([(self.product_to_reconcile, 1)]) + so.with_context(force_confirm_sale_order=True).action_confirm() + self._do_picking(so.picking_ids, fields.Datetime.now()) + self.assertTrue(so.unreconciled) + so._create_invoices() + invoice = so.invoice_ids + invoice.action_post() + # not reconcile until it is locked + self.assertTrue(so.unreconciled) + + def test_02_action_reconcile(self): + """Test reconcile.""" + so = self._create_sale([(self.product_to_reconcile, 1)]) + so.company_id.sale_reconcile_account_id = self.writeoff_acc + so.with_context(force_confirm_sale_order=True).action_confirm() + self._do_picking(so.picking_ids, fields.Datetime.now()) + so._create_invoices() + invoice = so.invoice_ids + invoice.action_post() + so.action_reconcile() + self.assertFalse(so.unreconciled) + + def test_03_action_reconcile(self): + """Test reconcile.""" + so = self._create_sale([(self.product_to_reconcile, 1)]) + so.company_id.sale_reconcile_account_id = self.writeoff_acc + so.with_context(force_confirm_sale_order=True).action_confirm() + self._do_picking(so.picking_ids, fields.Datetime.now()) + so._create_invoices() + self.assertTrue(so.unreconciled) + so.action_done() + so._compute_unreconciled() + self.assertFalse(so.unreconciled) + + def test_04_sale_mrp_anglo_saxon(self): + """Test sale order for kit, deliver and invoice and ensure + the iterim account is balanced and COGS is hit + """ + self.uom_unit = self.env["uom.uom"].create( + { + "name": "Test-Unit", + "category_id": self.categ_unit.id, + "factor": 1, + "uom_type": "bigger", + "rounding": 1.0, + } + ) + self.company = self.env.ref("base.main_company") + self.company.anglo_saxon_accounting = True + self.partner = self.partner_obj.create({"name": "Test Customer"}) + self.category = self.env.ref("product.product_category_1").copy( + { + "name": "Test category", + "property_valuation": "real_time", + "property_cost_method": "fifo", + } + ) + account_type = self.env["account.account.type"].create( + {"name": "RCV type", "type": "other", "internal_group": "asset"} + ) + self.account_receiv = self.env["account.account"].create( + { + "name": "Receivable", + "code": "RCV00", + "user_type_id": account_type.id, + "reconcile": True, + } + ) + account_expense = self.env["account.account"].create( + { + "name": "Expense", + "code": "EXP00", + "user_type_id": account_type.id, + "reconcile": True, + } + ) + account_output = self.env["account.account"].create( + { + "name": "Output", + "code": "OUT00", + "user_type_id": account_type.id, + "reconcile": True, + } + ) + account_valuation = self.env["account.account"].create( + { + "name": "Valuation", + "code": "STV00", + "user_type_id": account_type.id, + "reconcile": True, + } + ) + self.partner.property_account_receivable_id = self.account_receiv + self.category.property_account_income_categ_id = self.account_receiv + self.category.property_account_expense_categ_id = account_expense + self.category.property_stock_account_input_categ_id = self.account_receiv + self.category.property_stock_account_output_categ_id = account_output + self.category.property_stock_valuation_account_id = account_valuation + self.category.property_stock_journal = self.env["account.journal"].create( + {"name": "Stock journal", "type": "sale", "code": "STK00"} + ) + + Product = self.env["product.product"] + # for BMS finished product is kit storable + self.finished_product = Product.create( + { + "name": "Finished product", + "type": "product", + "uom_id": self.uom_unit.id, + "invoice_policy": "delivery", + "categ_id": self.category.id, + } + ) + self.component1 = Product.create( + { + "name": "Component 1", + "type": "product", + "uom_id": self.uom_unit.id, + "categ_id": self.category.id, + "standard_price": 20, + } + ) + self.component2 = Product.create( + { + "name": "Component 2", + "type": "product", + "uom_id": self.uom_unit.id, + "categ_id": self.category.id, + "standard_price": 10, + } + ) + self.env["stock.quant"].create( + { + "product_id": self.component1.id, + "location_id": self.env.ref("stock.stock_location_stock").id, + "quantity": 6.0, + } + ) + self.env["stock.quant"].create( + { + "product_id": self.component2.id, + "location_id": self.env.ref("stock.stock_location_stock").id, + "quantity": 3.0, + } + ) + self.bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": self.finished_product.product_tmpl_id.id, + "product_qty": 1.0, + "type": "phantom", + } + ) + BomLine = self.env["mrp.bom.line"] + BomLine.create( + { + "product_id": self.component1.id, + "product_qty": 2.0, + "bom_id": self.bom.id, + } + ) + BomLine.create( + { + "product_id": self.component2.id, + "product_qty": 1.0, + "bom_id": self.bom.id, + } + ) + + # Create a SO for a specific partner for three units of the + # finished product + so_vals = { + "partner_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "name": self.finished_product.name, + "product_id": self.finished_product.id, + "product_uom_qty": 3, + "product_uom": self.finished_product.uom_id.id, + "price_unit": self.finished_product.list_price, + }, + ) + ], + "pricelist_id": self.env.ref("product.list0").id, + "company_id": self.company.id, + } + self.so = self.env["sale.order"].create(so_vals) + # Validate the SO + self.so.action_confirm() + # Deliver the three finished products + pick = self.so.picking_ids + # To check the products on the picking + self.assertEqual( + pick.move_lines.mapped("product_id"), self.component1 | self.component2 + ) + self._do_picking( + pick.filtered(lambda p: p.state != "done"), fields.Datetime.now() + ) + # Create the invoice + self.so._create_invoices() + invoice = self.so.invoice_ids + invoice.action_post() + aml = invoice.line_ids + aml_expense = aml.filtered(lambda l: l.account_id == account_expense) + aml_output = aml.filtered(lambda l: l.account_id == account_output) + # Check that the cost of Good Sold entries are equal to: + # 3* (2 * 20 + 1 * 10) = 100 + self.assertEqual( + sum(aml_expense.mapped("debit")), + 150, + "Cost of Good Sold entry missing or mismatching", + ) + self.assertEqual( + sum(aml_output.mapped("credit")), 150, "GDNI missing or mismatching" + ) + # Now checking that the stock entries for the finished product are + # created and not the component (decide if that is derisable) + self.assertNotEqual( + aml.filtered( + lambda ml: ml.product_id == self.finished_product + and ml.account_id == account_expense + ), + self.env["account.move.line"], + ) + self.assertEqual( + aml.filtered( + lambda ml: ml.product_id == self.component1 + and ml.account_id == account_expense + ), + self.env["account.move.line"], + ) + + def test_06_dropship_not_reconcile_sale_journal_items(self): + """ + Create a fake dropship and lock the SO before receiving the customer + invoice. The SO should not close the stock interim output account + """ + # to create the fake dropship we create a incoming and attach the + # journals to the sale order craeted later + incoming = self._create_incoming(self.product_to_reconcile, 1) + self._do_picking(incoming, fields.Datetime.now()) + # We create the SO now and receive it + so = self._create_sale([(self.product_to_reconcile, 1)]) + so.with_context(force_confirm_sale_order=True).action_confirm() + self._do_picking(so.picking_ids, fields.Datetime.now()) + self.assertTrue(so.unreconciled) + # as long stock_dropshipping is not dependency, I force the SO to be in + # the journal items of the incoming + incoming_name = incoming.name + incoming_ji = self.env["account.move.line"].search( + [("move_id.ref", "=", incoming_name)] + ) + incoming_ji.write({"sale_line_id": so.order_line[0], "sale_order_id": so.id}) + # then I lock the so to force reconciliation + so.action_done() + so._compute_unreconciled() + self.assertFalse(so.unreconciled) + # the SO is reconciled and the stock interim deliverd account is not + # reconciled yet + for jii in incoming_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 + """ + so = self._create_sale([(self.product_to_reconcile, 1)]) + so.company_id.sale_reconcile_account_id = self.writeoff_acc + so.with_context(force_confirm_sale_order=True).action_confirm() + self._do_picking(so.picking_ids, fields.Datetime.now()) + # Invoice created and validated: + so._create_invoices() + invoice = so.invoice_ids + 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(so.state, "sale") + # The invoice is wrong so this is unreconciled + self.assertTrue(so.unreconciled) + so.action_done() + so._compute_unreconciled() + self.assertFalse(so.unreconciled) + # we check all the journals for the so have the same company + ji = self.env["account.move.line"].search( + [("sale_order_id", "=", so.id), ("move_id", "!=", invoice.id)] + ) + self.assertEqual(so.company_id, ji.mapped("company_id")) diff --git a/sale_unreconciled/views/res_config_settings_view.xml b/sale_unreconciled/views/res_config_settings_view.xml new file mode 100644 index 000000000000..d23f089b4561 --- /dev/null +++ b/sale_unreconciled/views/res_config_settings_view.xml @@ -0,0 +1,37 @@ + + + + res.config.settings.view.form.sale.unreconciled + res.config.settings + + + +

Sale Reconciling

+
+
+
+
+
+
+
+ + + + diff --git a/sale_unreconciled/views/sale_order_view.xml b/sale_unreconciled/views/sale_order_view.xml new file mode 100644 index 000000000000..3e438054dd2f --- /dev/null +++ b/sale_unreconciled/views/sale_order_view.xml @@ -0,0 +1,52 @@ + + + + sale.order.form - sale_unreconciled + sale.order + + + + + + + + + + + + + + sale.quotation.select - sale_unreconciled + sale.order + + + + + + + + diff --git a/setup/sale_unreconciled/odoo/addons/sale_unreconciled b/setup/sale_unreconciled/odoo/addons/sale_unreconciled new file mode 120000 index 000000000000..86337ab07f39 --- /dev/null +++ b/setup/sale_unreconciled/odoo/addons/sale_unreconciled @@ -0,0 +1 @@ +../../../../sale_unreconciled \ No newline at end of file diff --git a/setup/sale_unreconciled/setup.py b/setup/sale_unreconciled/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/sale_unreconciled/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)