From 9ff2d9f16a62e82136dd309afb5833e7c4d4baf1 Mon Sep 17 00:00:00 2001 From: Jigar Vaghela Date: Fri, 28 Oct 2022 06:54:15 +0000 Subject: [PATCH] [IMP] account: add support of financial year in sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For countries where the financial year is not from January to December so the sequence may contain two years like 2022-2023 where 2022 is the financial year start and 2023 is the financial year end and this sequence is reset when the financial year ends. so in this commit, we add support for this type of sequence * INV/2022-2023/00001 * INV/22-23/00001 * INV/2022-23/00001 closes odoo/odoo#147785 X-original-commit: 072085b32c435fa88d6765469cb0908b9b5c3074 Signed-off-by: William André (wan) --- addons/account/models/account_move.py | 26 ++++-- addons/account/models/sequence_mixin.py | 56 ++++++++++--- addons/account/tests/test_sequence_mixin.py | 93 +++++++++++++++++---- addons/account/wizard/account_resequence.py | 13 ++- 4 files changed, 154 insertions(+), 34 deletions(-) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 9eaab4e4466..6d87db7d72e 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1801,6 +1801,13 @@ class AccountMove(models.Model): "The year detected here is '%(year)s'.\n" "The incrementing number in this case is '%(formatted_seq)s'." ) + elif reset == 'year_range': + detected = _( + "The sequence will restart at 1 at the start of every financial year.\n" + "The financial start year detected here is '%(year)s'.\n" + "The financial end year detected here is '%(year_end)s'.\n" + "The incrementing number in this case is '%(formatted_seq)s'." + ) else: detected = _( "The sequence will never restart.\n" @@ -2652,14 +2659,13 @@ class AccountMove(models.Model): if not reference_move_name: reference_move_name = self.sudo().search(domain, order='date asc', limit=1).name sequence_number_reset = self._deduce_sequence_number_reset(reference_move_name) - if sequence_number_reset == 'year': - where_string += " AND date_trunc('year', date::timestamp without time zone) = date_trunc('year', %(date)s) " - param['date'] = self.date + date_start, date_end = self._get_sequence_date_range(sequence_number_reset) + where_string += """ AND date BETWEEN %(date_start)s AND %(date_end)s""" + param['date_start'] = date_start + param['date_end'] = date_end + if sequence_number_reset in ('year', 'year_range'): param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_monthly_regex.split('(?P')[0]) + '$' - elif sequence_number_reset == 'month': - where_string += " AND date_trunc('month', date::timestamp without time zone) = date_trunc('month', %(date)s) " - param['date'] = self.date - else: + elif sequence_number_reset == 'never': param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_yearly_regex.split('(?P')[0]) + '$' if param.get('anti_regex') and not self.journal_id.sequence_override_regex: @@ -2691,6 +2697,12 @@ class AccountMove(models.Model): starting_sequence = "P" + starting_sequence return starting_sequence + def _get_sequence_date_range(self, reset): + if reset == 'year_range': + company = self.company_id + return date_utils.get_fiscal_year(self.date, day=company.fiscalyear_last_day, month=int(company.fiscalyear_last_month)) + return super()._get_sequence_date_range(reset) + # ------------------------------------------------------------------------- # PAYMENT REFERENCE # ------------------------------------------------------------------------- diff --git a/addons/account/models/sequence_mixin.py b/addons/account/models/sequence_mixin.py index 29507e7509c..c520c489e1d 100644 --- a/addons/account/models/sequence_mixin.py +++ b/addons/account/models/sequence_mixin.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- +from datetime import date from odoo import api, fields, models, _ from odoo.exceptions import ValidationError from odoo.tools.misc import format_date -from odoo.tools import frozendict, mute_logger +from odoo.tools import frozendict, mute_logger, date_utils import re from collections import defaultdict @@ -23,6 +24,7 @@ class SequenceMixin(models.AbstractModel): _sequence_field = "name" _sequence_date_field = "date" _sequence_index = False + _sequence_year_range_regex = r'^(?:(?P.*?)(?P((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P\D)(?P((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P\D+?))?(?P\d*)(?P\D*?)$' _sequence_monthly_regex = r'^(?P.*?)(?P((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P\D*?)(?P(0[1-9]|1[0-2]))(?P\D+?)(?P\d*)(?P\D*?)$' _sequence_yearly_regex = r'^(?P.*?)(?P((?<=\D)|(?<=^))((19|20|21)?\d{2}))(?P\D+?)(?P\d*)(?P\D*?)$' _sequence_fixed_regex = r'^(?P.*?)(?P\d{0,9})(?P\D*?)$' @@ -47,9 +49,25 @@ class SequenceMixin(models.AbstractModel): field=sql.Identifier(self._sequence_field), )) + def _get_sequence_date_range(self, reset): + ref_date = fields.Date.to_date(self[self._sequence_date_field]) + if reset in ('year', 'year_range'): + return (date(ref_date.year, 1, 1), date(ref_date.year, 12, 31)) + if reset == 'month': + return date_utils.get_month(ref_date) + if reset == 'never': + return (date(1, 1, 1), date(9999, 1, 1)) + raise NotImplementedError(reset) + def _must_check_constrains_date_sequence(self): return True + def _year_match(self, format_value, date): + return format_value == self._truncate_year_to_length(date.year, len(str(format_value))) + + def _truncate_year_to_length(self, year, length): + return year % (10 ** length) + def _sequence_matches_date(self): self.ensure_one() date = fields.Date.to_date(self[self._sequence_date_field]) @@ -59,9 +77,11 @@ class SequenceMixin(models.AbstractModel): return True format_values = self._get_sequence_format_param(sequence)[1] + sequence_number_reset = self._deduce_sequence_number_reset(sequence) + year_start, year_end = self._get_sequence_date_range(sequence_number_reset) year_match = ( - not format_values["year"] - or format_values["year"] == date.year % 10 ** len(str(format_values["year"])) + (not format_values["year"] or self._year_match(format_values["year"], year_start)) + and (not format_values["year_end"] or self._year_match(format_values["year_end"], year_end)) ) month_match = not format_values['month'] or format_values['month'] == date.month return year_match and month_match @@ -115,13 +135,23 @@ class SequenceMixin(models.AbstractModel): """ for regex, ret_val, requirements in [ (self._sequence_monthly_regex, 'month', ['seq', 'month', 'year']), + (self._sequence_year_range_regex, 'year_range', ['seq', 'year', 'year_end']), (self._sequence_yearly_regex, 'year', ['seq', 'year']), (self._sequence_fixed_regex, 'never', ['seq']), ]: match = re.match(regex, name or '') if match: groupdict = match.groupdict() - if all(req in groupdict for req in requirements): + if ( + groupdict.get('year_end') and groupdict.get('year') + and ( + len(groupdict['year']) < len(groupdict['year_end']) + or self._truncate_year_to_length((int(groupdict['year']) + 1), len(groupdict['year_end'])) != int(groupdict['year_end']) + ) + ): + # year and year_end are not compatible for range (the difference is not 1) + continue + if all(groupdict.get(req) is not None for req in requirements): return ret_val raise ValidationError(_( 'The sequence regex should at least contain the seq grouping keys. For instance:\n' @@ -211,24 +241,27 @@ class SequenceMixin(models.AbstractModel): regex = self._sequence_fixed_regex if sequence_number_reset == 'year': regex = self._sequence_yearly_regex + elif sequence_number_reset == 'year_range': + regex = self._sequence_year_range_regex elif sequence_number_reset == 'month': regex = self._sequence_monthly_regex - format_values = re.match(regex, previous).groupdict() format_values['seq_length'] = len(format_values['seq']) - format_values['year_length'] = len(format_values.get('year', '')) + format_values['year_length'] = len(format_values.get('year') or '') + format_values['year_end_length'] = len(format_values.get('year_end') or '') if not format_values.get('seq') and 'prefix1' in format_values and 'suffix' in format_values: # if we don't have a seq, consider we only have a prefix and not a suffix format_values['prefix1'] = format_values['suffix'] format_values['suffix'] = '' - for field in ('seq', 'year', 'month'): + for field in ('seq', 'year', 'month', 'year_end'): format_values[field] = int(format_values.get(field) or 0) - placeholders = re.findall(r'(prefix\d|seq|suffix\d?|year|month)', regex) + placeholders = re.findall(r'\b(prefix\d|seq|suffix\d?|year|year_end|month)\b', regex) format = ''.join( "{seq:0{seq_length}d}" if s == 'seq' else "{month:02d}" if s == 'month' else "{year:0{year_length}d}" if s == 'year' else + "{year_end:0{year_end_length}d}" if s == 'year_end' else "{%s}" % s for s in placeholders ) @@ -250,10 +283,13 @@ class SequenceMixin(models.AbstractModel): last_sequence = self._get_last_sequence(relaxed=True) or self._get_starting_sequence() format_string, format_values = self._get_sequence_format_param(last_sequence) + sequence_number_reset = self._deduce_sequence_number_reset(last_sequence) if new: + date_start, date_end = self._get_sequence_date_range(sequence_number_reset) format_values['seq'] = 0 - format_values['year'] = self[self._sequence_date_field].year % (10 ** format_values['year_length']) - format_values['month'] = self[self._sequence_date_field].month + format_values['year'] = self._truncate_year_to_length(date_start.year, format_values['year_length']) + format_values['year_end'] = self._truncate_year_to_length(date_end.year, format_values['year_end_length']) + format_values['month'] = date_start.month # before flushing inside the savepoint (which may be rolled back!), make sure everything # is already flushed, otherwise we could lose non-sequence fields values, as the ORM believes diff --git a/addons/account/tests/test_sequence_mixin.py b/addons/account/tests/test_sequence_mixin.py index 771f69a0ccb..42d81ab7a6c 100644 --- a/addons/account/tests/test_sequence_mixin.py +++ b/addons/account/tests/test_sequence_mixin.py @@ -18,6 +18,7 @@ class TestSequenceMixinCommon(AccountTestInvoicingCommon): @classmethod def setUpClass(cls, chart_template_ref=None): super().setUpClass(chart_template_ref=chart_template_ref) + cls.company_data['company'].write({'fiscalyear_last_day': "31", 'fiscalyear_last_month': "3"}) cls.test_move = cls.create_move() @classmethod @@ -44,6 +45,15 @@ class TestSequenceMixinCommon(AccountTestInvoicingCommon): @tagged('post_install', '-at_install') class TestSequenceMixin(TestSequenceMixinCommon): + def assertNameAtDate(self, date, name): + test = self.create_move(date=date) + test.action_post() + self.assertEqual(test.name, name) + return test + + def set_sequence(self, date, name): + return self.create_move(date=date, name=name)._post(soft=False) + def test_sequence_change_date(self): """Change the sequence when we change the date iff it has never been posted.""" # Check setup @@ -280,6 +290,9 @@ class TestSequenceMixin(TestSequenceMixinCommon): """Test different format of sequences and what it becomes on another period""" sequences = [ ('JRNL/2016/00001', 'JRNL/2016/00002', 'JRNL/2016/00003', 'JRNL/2017/00001'), + ('JRNL/2015-2016/00001', 'JRNL/2015-2016/00002', 'JRNL/2016-2017/00001', 'JRNL/2016-2017/00002'), + ('JRNL/2015-16/00001', 'JRNL/2015-16/00002', 'JRNL/2016-17/00001', 'JRNL/2016-17/00002'), + ('JRNL/15-16/00001', 'JRNL/15-16/00002', 'JRNL/16-17/00001', 'JRNL/16-17/00002'), ('1234567', '1234568', '1234569', '1234570'), ('20190910', '20190911', '20190912', '20190913'), ('2016-0910', '2016-0911', '2016-0912', '2017-0001'), @@ -475,32 +488,82 @@ class TestSequenceMixin(TestSequenceMixinCommon): self.assertEqual(copies[5].name, 'XMISC/2019/10005') self.assertEqual(copies[5].state, 'draft') + def test_journal_resequence_in_between_2_years_pattern(self): + """Resequence XMISC/2023-2024/00001 into XMISC/23-24/00001.""" + self.test_move.name = 'XMISC/2015-2016/00001' + invoices = ( + self.create_move(date="2023-03-01", post=True) + + self.create_move(date="2023-03-02", post=True) + + self.create_move(date="2023-03-03", post=True) + + self.create_move(date="2023-04-01", post=True) + + self.create_move(date="2023-04-02", post=True) + ) + self.assertRecordValues(invoices, ( + {'name': 'XMISC/2022-2023/00001', 'state': 'posted'}, + {'name': 'XMISC/2022-2023/00002', 'state': 'posted'}, + {'name': 'XMISC/2022-2023/00003', 'state': 'posted'}, + {'name': 'XMISC/2023-2024/00001', 'state': 'posted'}, + {'name': 'XMISC/2023-2024/00002', 'state': 'posted'}, + )) + + # Call the resequence wizard and change the sequence to XMISC/22-23/00001 + # By default the sequence order should be kept + resequence_wizard = Form(self.env['account.resequence.wizard'].with_context(active_ids=invoices.ids, active_model='account.move')) + resequence_wizard.first_name = "XMISC/22-23/00001" + new_values = json.loads(resequence_wizard.new_values) + # Ensure consistencies of sequence displayed in the UI + self.assertEqual(new_values[str(invoices[0].id)]['new_by_name'], 'XMISC/22-23/00001') + self.assertEqual(new_values[str(invoices[1].id)]['new_by_name'], 'XMISC/22-23/00002') + self.assertEqual(new_values[str(invoices[2].id)]['new_by_name'], 'XMISC/22-23/00003') + self.assertEqual(new_values[str(invoices[3].id)]['new_by_name'], 'XMISC/23-24/00001') + self.assertEqual(new_values[str(invoices[4].id)]['new_by_name'], 'XMISC/23-24/00002') + resequence_wizard.save().resequence() + + # Ensure the resequencing gave the same result as what was expected + self.assertRecordValues(invoices, ( + {'name': 'XMISC/22-23/00001', 'state': 'posted'}, + {'name': 'XMISC/22-23/00002', 'state': 'posted'}, + {'name': 'XMISC/22-23/00003', 'state': 'posted'}, + {'name': 'XMISC/23-24/00001', 'state': 'posted'}, + {'name': 'XMISC/23-24/00002', 'state': 'posted'}, + )) + def test_sequence_get_more_specific(self): """There is the ability to change the format (i.e. from yearly to montlhy).""" - def test_date(date, name): - test = self.create_move(date=date) - test.action_post() - self.assertEqual(test.name, name) - - def set_sequence(date, name): - return self.create_move(date=date, name=name)._post() - # Start with a continuous sequence self.test_move.name = 'MISC/00001' # Change the prefix to reset every year starting in 2017 - new_year = set_sequence(self.test_move.date + relativedelta(years=1), 'MISC/2017/00001') + new_year = self.set_sequence(self.test_move.date + relativedelta(years=1), 'MISC/2017/00001') # Change the prefix to reset every month starting in February 2017 - new_month = set_sequence(new_year.date + relativedelta(months=1), 'MISC/2017/02/00001') + new_month = self.set_sequence(new_year.date + relativedelta(months=1), 'MISC/2017/02/00001') - test_date(self.test_move.date, 'MISC/00002') # Keep the old prefix in 2016 - test_date(new_year.date, 'MISC/2017/00002') # Keep the new prefix in 2017 - test_date(new_month.date, 'MISC/2017/02/00002') # Keep the new prefix in February 2017 + self.assertNameAtDate(self.test_move.date, 'MISC/00002') # Keep the old prefix in 2016 + self.assertNameAtDate(new_year.date, 'MISC/2017/00002') # Keep the new prefix in 2017 + self.assertNameAtDate(new_month.date, 'MISC/2017/02/00002') # Keep the new prefix in February 2017 + + # Go fiscal year in March + # This will break the prefix of 2017 set previously and we will use the fiscal year prefix as of now + start_fiscal = self.set_sequence(new_year.date + relativedelta(months=2), 'MISC/2016-2017/00001') + + self.assertNameAtDate(self.test_move.date, 'MISC/00003') # Keep the old prefix in 2016 + self.assertNameAtDate(new_year.date, 'MISC/2016-2017/00002') # Prefix in January 2017 changed! + self.assertNameAtDate(new_month.date, 'MISC/2017/02/00003') # Keep the new prefix in February 2017 + self.assertNameAtDate(start_fiscal.date, 'MISC/2016-2017/00003') # Keep the new prefix in March 2017 # Change the prefix to never reset (again) year starting in 2018 (Please don't do that) - reset_never = set_sequence(self.test_move.date + relativedelta(years=2), 'MISC/00100') - test_date(reset_never.date, 'MISC/00101') # Keep the new prefix in 2018 + reset_never = self.set_sequence(self.test_move.date + relativedelta(years=2), 'MISC/00100') + self.assertNameAtDate(reset_never.date, 'MISC/00101') # Keep the new prefix in 2018 + + def test_fiscal_vs_monthly(self): + """Monthly sequence has priority over 2 digit financial year sequence but can be overridden.""" + self.set_sequence('2101-02-01', 'MISC/01-02/00001') + move = self.assertNameAtDate('2101-03-01', 'MISC/01-03/00001') + + move.journal_id.sequence_override_regex = move._sequence_year_range_regex + move.name = 'MISC/00-01/00001' + self.assertNameAtDate('2101-03-01', 'MISC/00-01/00002') def test_resequence_clash(self): """Resequence doesn't clash when it uses a name set in the same batch diff --git a/addons/account/wizard/account_resequence.py b/addons/account/wizard/account_resequence.py index 2e5016b25bb..417cbd3dfe2 100644 --- a/addons/account/wizard/account_resequence.py +++ b/addons/account/wizard/account_resequence.py @@ -68,6 +68,7 @@ class ReSequenceWizard(models.TransientModel): for i, line in enumerate(new_values): if i < 3 or i == len(new_values) - 1 or line['new_by_name'] != line['new_by_date'] \ or (self.sequence_number_reset == 'year' and line['server-date'][0:4] != previous_line['server-date'][0:4])\ + or (self.sequence_number_reset == 'year_range' and line['server-year-start-date'][0:4] != previous_line['server-year-start-date'][0:4])\ or (self.sequence_number_reset == 'month' and line['server-date'][0:7] != previous_line['server-date'][0:7]): if in_elipsis: changeLines.append({'id': 'other_' + str(line['id']), 'current_name': _('... (%s other)', in_elipsis), 'new_by_name': '...', 'new_by_date': '...', 'date': '...'}) @@ -91,8 +92,12 @@ class ReSequenceWizard(models.TransientModel): relative to the preview widget. """ def _get_move_key(move_id): + company = move_id.company_id + year_start, year_end = get_fiscal_year(move_id.date, day=company.fiscalyear_last_day, month=int(company.fiscalyear_last_month)) if self.sequence_number_reset == 'year': return move_id.date.year + elif self.sequence_number_reset == 'year_range': + return "%s-%s"%(year_start.year, year_end.year) elif self.sequence_number_reset == 'month': return (move_id.date.year, move_id.date.month) return 'default' @@ -104,10 +109,12 @@ class ReSequenceWizard(models.TransientModel): moves_by_period[_get_move_key(move)] += move seq_format, format_values = record.move_ids[0]._get_sequence_format_param(record.first_name) + sequence_number_reset = record.move_ids[0]._deduce_sequence_number_reset(record.first_name) new_values = {} for j, period_recs in enumerate(moves_by_period.values()): # compute the new values period by period + year_start, year_end = period_recs[0]._get_sequence_date_range(sequence_number_reset) for move in period_recs: new_values[move.id] = { 'id': move.id, @@ -115,12 +122,14 @@ class ReSequenceWizard(models.TransientModel): 'state': move.state, 'date': format_date(self.env, move.date), 'server-date': str(move.date), + 'server-year-start-date': str(year_start), } new_name_list = [seq_format.format(**{ **format_values, - 'year': period_recs[0].date.year % (10 ** format_values['year_length']), - 'month': period_recs[0].date.month, + 'month': year_start.month, + 'year_end': year_end.year % (10 ** format_values['year_end_length']), + 'year': year_start.year % (10 ** format_values['year_length']), 'seq': i + (format_values['seq'] if j == (len(moves_by_period)-1) else 1), }) for i in range(len(period_recs))]