[IMP] account: add support of financial year in sequence

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) <wan@odoo.com>
This commit is contained in:
Jigar Vaghela
2023-12-29 19:47:34 +00:00
committed by william-andre
parent bc0b090cd6
commit 9ff2d9f16a
4 changed files with 154 additions and 34 deletions
+19 -7
View File
@@ -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<seq>')[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<seq>')[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
# -------------------------------------------------------------------------
+46 -10
View File
@@ -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<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D)(?P<year_end>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix3>\D+?))?(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_monthly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D*?)(?P<month>(0[1-9]|1[0-2]))(?P<prefix3>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_yearly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)?\d{2}))(?P<prefix2>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_fixed_regex = r'^(?P<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\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
+78 -15
View File
@@ -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
+11 -2
View File
@@ -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))]