[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:
committed by
william-andre
parent
bc0b090cd6
commit
9ff2d9f16a
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user