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))]