diff --git a/account_move_name_sequence/README.rst b/account_move_name_sequence/README.rst new file mode 100644 index 00000000..2627cab2 --- /dev/null +++ b/account_move_name_sequence/README.rst @@ -0,0 +1 @@ +Will be auto-generated from the readme subdir diff --git a/account_move_name_sequence/__init__.py b/account_move_name_sequence/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/account_move_name_sequence/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_move_name_sequence/__manifest__.py b/account_move_name_sequence/__manifest__.py new file mode 100644 index 00000000..b0b62405 --- /dev/null +++ b/account_move_name_sequence/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Account Move Number Sequence", + "version": "14.0.1.0.0", + "category": "Accounting", + "license": "AGPL-3", + "summary": "Generate journal entry number from sequence", + "author": "Akretion,Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/account-financial-tools", + "depends": ["account"], + "data": [ + "views/account_journal.xml", + "security/ir.model.access.csv", + ], + "installable": True, +} diff --git a/account_move_name_sequence/models/__init__.py b/account_move_name_sequence/models/__init__.py new file mode 100644 index 00000000..067db8c5 --- /dev/null +++ b/account_move_name_sequence/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_journal +from . import account_move diff --git a/account_move_name_sequence/models/account_journal.py b/account_move_name_sequence/models/account_journal.py new file mode 100644 index 00000000..2d616d00 --- /dev/null +++ b/account_move_name_sequence/models/account_journal.py @@ -0,0 +1,96 @@ +# Copyright 2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + sequence_id = fields.Many2one( + "ir.sequence", + string="Entry Sequence", + copy=False, + check_company=True, + domain="[('company_id', '=', company_id)]", + help="This sequence will be used to generate the journal entry number.", + ) + refund_sequence = fields.Boolean( + string="Dedicated Credit Note Sequence", + default=True, + help="If enabled, you will be able to setup a sequence dedicated for refunds.", + ) + refund_sequence_id = fields.Many2one( + "ir.sequence", + string="Credit Note Entry Sequence", + copy=False, + check_company=True, + domain="[('company_id', '=', company_id)]", + help="This sequence will be used to generate the journal entry number for refunds.", + ) + + @api.constrains("refund_sequence_id", "sequence_id") + def _check_journal_sequence(self): + for journal in self: + if ( + journal.refund_sequence_id + and journal.sequence_id + and journal.refund_sequence_id == journal.sequence_id + ): + raise ValidationError( + _( + "On journal '%s', the same sequence is used as " + "Entry Sequence and Credit Note Entry Sequence." + ) + % journal.display_name + ) + if journal.sequence_id and not journal.sequence_id.company_id: + raise ValidationError( + _( + "The company is not set on sequence '%s' configured on " + "journal '%s'." + ) + % (journal.sequence_id.display_name, journal.display_name) + ) + if journal.refund_sequence_id and not journal.refund_sequence_id.company_id: + raise ValidationError( + _( + "The company is not set on sequence '%s' configured as " + "credit note sequence of journal '%s'." + ) + % (journal.refund_sequence_id.display_name, journal.display_name) + ) + + @api.model + def create(self, vals): + if not vals.get("sequence_id"): + vals["sequence_id"] = self._create_sequence(vals).id + if ( + vals.get("type") in ("sale", "purchase") + and vals.get("refund_sequence") + and not vals.get("refund_sequence_id") + ): + vals["refund_sequence_id"] = self._create_sequence(vals, refund=True).id + return super().create(vals) + + @api.model + def _prepare_sequence(self, vals, refund=False): + code = vals.get("code") and vals["code"].upper() or "" + prefix = "%s%s/%%(range_year)s/" % (refund and "R" or "", code) + seq_vals = { + "name": "%s%s" + % (vals.get("name", _("Sequence")), refund and _("Refund") + " " or ""), + "company_id": vals.get("company_id") or self.env.company.id, + "implementation": "no_gap", + "prefix": prefix, + "padding": 4, + "use_date_range": True, + } + return seq_vals + + @api.model + def _create_sequence(self, vals, refund=False): + seq_vals = self._prepare_sequence(vals, refund=refund) + return self.env["ir.sequence"].sudo().create(seq_vals) diff --git a/account_move_name_sequence/models/account_move.py b/account_move_name_sequence/models/account_move.py new file mode 100644 index 00000000..986d64dd --- /dev/null +++ b/account_move_name_sequence/models/account_move.py @@ -0,0 +1,32 @@ +# Copyright 2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _compute_name(self): + for move in self.filtered( + lambda m: (m.name == "/" or not m.name) + and m.state == "posted" + and m.journal_id + and m.journal_id.sequence_id + ): + if ( + move.move_type in ("out_refund", "in_refund") + and move.journal_id.type in ("sale", "purchase") + and move.journal_id.refund_sequence + and move.journal_id.refund_sequence_id + ): + seq = move.journal_id.refund_sequence_id + else: + seq = move.journal_id.sequence_id + move.name = seq.next_by_id(sequence_date=move.date) + super()._compute_name() + for move in self.filtered( + lambda m: m.name and m.name != "/" and m.state != "posted" + ): + move.name = "/" diff --git a/account_move_name_sequence/readme/CONFIGURE.rst b/account_move_name_sequence/readme/CONFIGURE.rst new file mode 100644 index 00000000..1b4bf54b --- /dev/null +++ b/account_move_name_sequence/readme/CONFIGURE.rst @@ -0,0 +1,3 @@ +On the form view of an account journal, in the first tab, there is a many2one link to the sequence. When you create a new journal, you can keep this field empty and a new sequence will be automatically created when you save the journal. + +On sale and purchase journals, you have an additionnal option to have another sequence dedicated to refunds. diff --git a/account_move_name_sequence/readme/CONTRIBUTORS.rst b/account_move_name_sequence/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..ff65d68c --- /dev/null +++ b/account_move_name_sequence/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexis de Lattre diff --git a/account_move_name_sequence/readme/DESCRIPTION.rst b/account_move_name_sequence/readme/DESCRIPTION.rst new file mode 100644 index 00000000..7ec4dd21 --- /dev/null +++ b/account_move_name_sequence/readme/DESCRIPTION.rst @@ -0,0 +1,14 @@ +In Odoo version 13.0 and previous versions, the number of journal entries was generated from a sequence configured on the journal. + +In Odoo version 14.0, the number of journal entries can be manually set by the user. Then, the number attributed for next journal entries in the same journal is computed by a complex piece of code that guesses the format of the journal entry number from the number of the journal entry which was manually entered by the user. It has several drawbacks: + +* the available options for the sequence are limited, +* it is not possible to configure the sequence in advance before the deployment in production, +* as it is error-prone, they added a *Resequence* wizard to re-generate the journal entry numbers, which can be considered as illegal in many countries, +* the `piece of code `_ that handle this is not easy to understand and quite difficult to debug. + +For those like me who think that the implementation before Odoo v14.0 was much better, for the accountants who think it should not be possible to manually enter the sequence of a customer invoice, for the auditor who consider that resequencing journal entries is prohibited by law, this module may be a solution to get out of the nightmare. + +The field names used in this module to configure the sequence on the journal are exactly the same as in Odoo version 13.0 and previous versions. That way, if you migrate to Odoo version 14.0 and you install this module immediately after the migration, you should keep the previous behavior and the same sequences will continue to be used. + +The module removes access to the *Resequence* wizard on journal entries. diff --git a/account_move_name_sequence/security/ir.model.access.csv b/account_move_name_sequence/security/ir.model.access.csv new file mode 100644 index 00000000..6247bc1e --- /dev/null +++ b/account_move_name_sequence/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 +account.access_account_resequence,Remove rights on account.resequence.wizard,account.model_account_resequence_wizard,account.group_account_manager,0,0,0,0 diff --git a/account_move_name_sequence/tests/__init__.py b/account_move_name_sequence/tests/__init__.py new file mode 100644 index 00000000..5de02aaf --- /dev/null +++ b/account_move_name_sequence/tests/__init__.py @@ -0,0 +1 @@ +from . import test_account_move_name_seq diff --git a/account_move_name_sequence/tests/test_account_move_name_seq.py b/account_move_name_sequence/tests/test_account_move_name_seq.py new file mode 100644 index 00000000..1ba0f2ac --- /dev/null +++ b/account_move_name_sequence/tests/test_account_move_name_seq.py @@ -0,0 +1,107 @@ +# Copyright 2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from odoo import fields +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestAccountMoveNameSequence(TransactionCase): + def setUp(self): + super().setUp() + self.company = self.env.ref("base.main_company") + self.misc_journal = self.env["account.journal"].create( + { + "name": "Test Journal Move name seq", + "code": "ADLM", + "type": "general", + "company_id": self.company.id, + } + ) + self.purchase_journal = self.env["account.journal"].create( + { + "name": "Test Purchase Journal Move name seq", + "code": "ADLP", + "type": "purchase", + "company_id": self.company.id, + "refund_sequence": True, + } + ) + self.accounts = self.env["account.account"].search( + [("company_id", "=", self.company.id)], limit=2 + ) + self.account1 = self.accounts[0] + self.account2 = self.accounts[1] + self.date = datetime.now() + + def test_seq_creation(self): + self.assertTrue(self.misc_journal.sequence_id) + seq = self.misc_journal.sequence_id + self.assertEqual(seq.company_id, self.company) + self.assertEqual(seq.implementation, "no_gap") + self.assertEqual(seq.padding, 4) + self.assertTrue(seq.use_date_range) + self.assertTrue(self.purchase_journal.sequence_id) + self.assertTrue(self.purchase_journal.refund_sequence_id) + seq = self.purchase_journal.refund_sequence_id + self.assertEqual(seq.company_id, self.company) + self.assertEqual(seq.implementation, "no_gap") + self.assertEqual(seq.padding, 4) + self.assertTrue(seq.use_date_range) + + def test_misc_move_name(self): + move = self.env["account.move"].create( + { + "date": self.date, + "journal_id": self.misc_journal.id, + "line_ids": [ + (0, 0, {"account_id": self.account1.id, "debit": 10}), + (0, 0, {"account_id": self.account2.id, "credit": 10}), + ], + } + ) + self.assertEqual(move.name, "/") + move.action_post() + seq = self.misc_journal.sequence_id + move_name = "%s%s" % (seq.prefix, "1".zfill(seq.padding)) + move_name = move_name.replace("%(range_year)s", str(self.date.year)) + self.assertEqual(move.name, move_name) + self.assertTrue(seq.date_range_ids) + drange_count = self.env["ir.sequence.date_range"].search_count( + [ + ("sequence_id", "=", seq.id), + ("date_from", "=", fields.Date.add(self.date, month=1, day=1)), + ] + ) + self.assertEqual(drange_count, 1) + + def test_in_refund(self): + in_refund_invoice = self.env["account.move"].create( + { + "journal_id": self.purchase_journal.id, + "invoice_date": self.date, + "partner_id": self.env.ref("base.res_partner_3").id, + "move_type": "in_refund", + "invoice_line_ids": [ + ( + 0, + 0, + { + "account_id": self.account1.id, + "price_unit": 42.0, + "quantity": 12, + }, + ) + ], + } + ) + self.assertEqual(in_refund_invoice.name, "/") + in_refund_invoice.action_post() + seq = self.purchase_journal.refund_sequence_id + move_name = "%s%s" % (seq.prefix, "1".zfill(seq.padding)) + move_name = move_name.replace("%(range_year)s", str(self.date.year)) + self.assertEqual(in_refund_invoice.name, move_name) diff --git a/account_move_name_sequence/views/account_journal.xml b/account_move_name_sequence/views/account_journal.xml new file mode 100644 index 00000000..8c557014 --- /dev/null +++ b/account_move_name_sequence/views/account_journal.xml @@ -0,0 +1,34 @@ + + + + + + account.journal + + + + + + + + + + +