diff options
| author | wan <wan@odoo.com> | 2019-11-27 15:57:42 +0000 |
|---|---|---|
| committer | wan <wan@odoo.com> | 2020-02-05 13:59:43 +0000 |
| commit | dfd01b8c5c7e1177f37bf199790a0732a61eed78 (patch) | |
| tree | 6ebb7f50ba554fb4138954c958f799836aae91a7 /addons/account/models | |
| parent | a6d7c7b04a48669037e6568849a26571602386f6 (diff) | |
[IMP] account: rework name sequences
Task 2146469
Rework the name computation:
* It doesn't use ir.sequence anymore
* It is an editable computed field
* Add a renaming tool
This allows the user to tweak the sequences more easily than having to
get through the ir.sequence settings.
The sequence depends on a parameter of the journal: continuous, montly
and yearly restart of the sequence. Everytime a new period is started,
try to make a pattern form another period.
The incrementing is done by taking the previous name, ordered
lexicographically, splitting it by taking the digits at the end, adding
1 and re contstruct with the prefix.
** This means there could be cases where the prefix has a big importance
on the next number. For instance, if you have
INVOICE/2019/0001
INVOICE/2019/0002
and then rename the last one to INV/2019/0002, don't expect the next
number to be INV/2019/0003. It will be INVOICE/2019/0002 (again) because
the highest number was INVOICE/2019/0001.
You will then end up with
INVOICE/2019/0001
INV/2019/0002
INVOICE/2019/0002
closes odoo/odoo#41485
Related: odoo/enterprise#7189
Signed-off-by: Quentin De Paoli (qdp) <qdp@openerp.com>
Diffstat (limited to 'addons/account/models')
| -rw-r--r-- | addons/account/models/__init__.py | 1 | ||||
| -rw-r--r-- | addons/account/models/account.py | 117 | ||||
| -rw-r--r-- | addons/account/models/account_bank_statement.py | 43 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 220 | ||||
| -rw-r--r-- | addons/account/models/sequence_mixin.py | 125 |
5 files changed, 280 insertions, 226 deletions
diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index ff544440e12..7ef7d6a36d6 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from . import sequence_mixin from . import partner from . import account from . import account_reconcile_model diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 48385ebee96..787e67172df 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -921,7 +921,7 @@ class AccountJournal(models.Model): return self.__get_bank_statements_available_sources() name = fields.Char(string='Journal Name', required=True) - code = fields.Char(string='Short Code', size=5, required=True, help="The journal entries of this journal will be named using this prefix.") + code = fields.Char(string='Short Code', size=5, required=True, help="Shorter name used for display. The journal entries of this journal will also be named using this prefix by default.") active = fields.Boolean(default=True, help="Set active to false to hide the Journal without removing it.") type = fields.Selection([ ('sale', 'Sales'), @@ -944,19 +944,7 @@ class AccountJournal(models.Model): domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", help="It acts as a default account for debit amount", ondelete='restrict') restrict_mode_hash_table = fields.Boolean(string="Lock Posted Entries with Hash", help="If ticked, the accounting entry or invoice receives a hash as soon as it is posted and cannot be modified anymore.") - sequence_id = fields.Many2one('ir.sequence', string='Entry Sequence', - help="This field contains the information related to the numbering of the journal entries of this journal.", required=True, copy=False) - refund_sequence_id = fields.Many2one('ir.sequence', string='Credit Note Entry Sequence', - help="This field contains the information related to the numbering of the credit note entries of this journal.", copy=False) sequence = fields.Integer(help='Used to order Journals in the dashboard view', default=10) - sequence_number_next = fields.Integer(string='Next Number', - help='The next sequence number will be used for the next invoice.', - compute='_compute_seq_number_next', - inverse='_inverse_seq_number_next') - refund_sequence_number_next = fields.Integer(string='Credit Notes Next Number', - help='The next sequence number will be used for the next credit note.', - compute='_compute_refund_seq_number_next', - inverse='_inverse_refund_seq_number_next') invoice_reference_type = fields.Selection(string='Communication Type', required=True, selection=[('none', 'Free'), ('partner', 'Based on Customer'), ('invoice', 'Based on Invoice')], default='invoice', help='You can set here the default communication that will appear on customer invoices, once validated, to help the customer to refer to that particular invoice when making the payment.') invoice_reference_model = fields.Selection(string='Communication Standard', required=True, selection=[('odoo', 'Odoo'),('euro', 'European')], default='odoo', help="You can choose different models for each type of reference. The default one is the Odoo reference.") @@ -967,6 +955,11 @@ class AccountJournal(models.Model): help="Company related to this journal") refund_sequence = fields.Boolean(string='Dedicated Credit Note Sequence', help="Check this box if you don't want to share the same sequence for invoices and credit notes made from this journal", default=False) + sequence_override_regex = fields.Text(help="Technical field used to enforce complex sequence composition that the system would normally misunderstand.\n"\ + "This is a regex that can include all the following capture groups: prefix1, year, prefix2, month, prefix3, seq, suffix.\n"\ + "The prefix* groups are the separators between the year, month and the actual increasing sequence number (seq).\n"\ + + "e.g: ^(?P<prefix1>.*?)(?P<year>\d{4})(?P<prefix2>\D*?)(?P<month>\d{2})(?P<prefix3>\D+?)(?P<seq>\d+)(?P<suffix>\D*?)$") inbound_payment_method_ids = fields.Many2many('account.payment.method', 'account_journal_inbound_payment_method_rel', 'journal_id', 'inbound_payment_method', domain=[('payment_type', '=', 'inbound')], string='For Incoming Payments', default=lambda self: self._default_inbound_payment_methods(), @@ -1009,50 +1002,6 @@ class AccountJournal(models.Model): for record in self: record.alias_domain = alias_domain - # do not depend on 'sequence_id.date_range_ids', because - # sequence_id._get_current_sequence() may invalidate it! - @api.depends('sequence_id.use_date_range', 'sequence_id.number_next_actual') - def _compute_seq_number_next(self): - '''Compute 'sequence_number_next' according to the current sequence in use, - an ir.sequence or an ir.sequence.date_range. - ''' - for journal in self: - if journal.sequence_id: - sequence = journal.sequence_id._get_current_sequence() - journal.sequence_number_next = sequence.number_next_actual - else: - journal.sequence_number_next = 1 - - def _inverse_seq_number_next(self): - '''Inverse 'sequence_number_next' to edit the current sequence next number. - ''' - for journal in self: - if journal.sequence_id and journal.sequence_number_next: - sequence = journal.sequence_id._get_current_sequence() - sequence.sudo().number_next = journal.sequence_number_next - - # do not depend on 'refund_sequence_id.date_range_ids', because - # refund_sequence_id._get_current_sequence() may invalidate it! - @api.depends('refund_sequence_id.use_date_range', 'refund_sequence_id.number_next_actual') - def _compute_refund_seq_number_next(self): - '''Compute 'sequence_number_next' according to the current sequence in use, - an ir.sequence or an ir.sequence.date_range. - ''' - for journal in self: - if journal.refund_sequence_id and journal.refund_sequence: - sequence = journal.refund_sequence_id._get_current_sequence() - journal.refund_sequence_number_next = sequence.number_next_actual - else: - journal.refund_sequence_number_next = 1 - - def _inverse_refund_seq_number_next(self): - '''Inverse 'refund_sequence_number_next' to edit the current sequence next number. - ''' - for journal in self: - if journal.refund_sequence_id and journal.refund_sequence and journal.refund_sequence_number_next: - sequence = journal.refund_sequence_id._get_current_sequence() - sequence.sudo().number_next = journal.refund_sequence_number_next - @api.constrains('type_control_ids') def _constrains_type_control_ids(self): self.env['account.move.line'].flush(['account_id', 'journal_id']) @@ -1195,14 +1144,6 @@ class AccountJournal(models.Model): 'company_id': company.id, 'partner_id': company.partner_id.id, }) - if ('code' in vals and journal.code != vals['code']): - if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1): - raise UserError(_('This journal already contains items, therefore you cannot modify its short name.')) - new_prefix = self._get_sequence_prefix(vals['code'], refund=False) - journal.sequence_id.write({'prefix': new_prefix}) - if journal.refund_sequence_id: - new_prefix = self._get_sequence_prefix(vals['code'], refund=True) - journal.refund_sequence_id.write({'prefix': new_prefix}) if 'currency_id' in vals: if not 'default_debit_account_id' in vals and journal.default_debit_account_id: journal.default_debit_account_id.currency_id = vals['currency_id'] @@ -1230,16 +1171,6 @@ class AccountJournal(models.Model): if 'bank_acc_number' in vals: for journal in self.filtered(lambda r: r.type == 'bank' and not r.bank_account_id): journal.set_bank_account(vals.get('bank_acc_number'), vals.get('bank_id')) - # create the relevant refund sequence - if vals.get('refund_sequence'): - for journal in self.filtered(lambda j: j.type in ('sale', 'purchase') and not j.refund_sequence_id): - journal_vals = { - 'name': journal.name, - 'company_id': journal.company_id.id, - 'code': journal.code, - 'refund_sequence_number_next': vals.get('refund_sequence_number_next', journal.refund_sequence_number_next), - } - journal.refund_sequence_id = self.sudo()._create_sequence(journal_vals, refund=True).id # Changing the 'post_at' option will post the draft payment moves and change the related invoices' state. if 'post_at' in vals and vals['post_at'] != 'bank_rec': draft_moves = self.env['account.move'].search([('journal_id', 'in', self.ids), ('state', '=', 'draft')]) @@ -1253,33 +1184,6 @@ class AccountJournal(models.Model): return result @api.model - def _get_sequence_prefix(self, code, refund=False): - prefix = code.upper() - if refund: - prefix = 'R' + prefix - return prefix + '/%(range_year)s/' - - @api.model - def _create_sequence(self, vals, refund=False): - """ Create new no_gap entry sequence for every new Journal""" - prefix = self._get_sequence_prefix(vals['code'], refund) - seq_name = refund and vals['code'] + _(': Refund') or vals['code'] - seq = { - 'name': _('%s Sequence') % seq_name, - 'implementation': 'no_gap', - 'prefix': prefix, - 'padding': 4, - 'number_increment': 1, - 'use_date_range': True, - } - if 'company_id' in vals: - seq['company_id'] = vals['company_id'] - seq = self.env['ir.sequence'].create(seq) - seq_date_range = seq._get_current_sequence() - seq_date_range.number_next = refund and vals.get('refund_sequence_number_next', 1) or vals.get('sequence_number_next', 1) - return seq - - @api.model def _prepare_liquidity_account(self, name, company, currency_id, type): ''' This function prepares the value to use for the creation of the default debit and credit accounts of a @@ -1353,11 +1257,6 @@ class AccountJournal(models.Model): if 'refund_sequence' not in vals: vals['refund_sequence'] = vals['type'] in ('sale', 'purchase') - # We just need to create the relevant sequences according to the chosen options - if not vals.get('sequence_id'): - vals.update({'sequence_id': self.sudo()._create_sequence(vals).id}) - if vals.get('type') in ('sale', 'purchase') and vals.get('refund_sequence') and not vals.get('refund_sequence_id'): - vals.update({'refund_sequence_id': self.sudo()._create_sequence(vals, refund=True).id}) journal = super(AccountJournal, self.with_context(mail_create_nolog=True)).create(vals) if 'alias_name' in vals: journal._update_mail_alias(vals) @@ -1622,9 +1521,9 @@ class AccountTax(models.Model): JOIN account_tax tax ON tax.id = line.tax_line_id WHERE line.tax_line_id IN %s AND line.company_id != tax.company_id - + UNION ALL - + SELECT line.id FROM account_move_line_account_tax_rel tax_rel JOIN account_tax tax ON tax.id = tax_rel.account_tax_id diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 416f6b18493..124484fc9ce 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -10,6 +10,7 @@ from odoo.exceptions import UserError, ValidationError import time import math import base64 +import re class AccountCashboxLine(models.Model): @@ -220,7 +221,7 @@ class AccountBankStatement(models.Model): _name = "account.bank.statement" _description = "Bank Statement" _order = "date desc, name desc, id desc" - _inherit = ['mail.thread'] + _inherit = ['mail.thread', 'sequence.mixin'] name = fields.Char(string='Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True) reference = fields.Char(string='External Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True, help="Used to hold the reference of the external mean that created this statement (name of imported file, reference of online synchronization...)") @@ -410,18 +411,44 @@ class AccountBankStatement(models.Model): """ Changes statement state to Running.""" for statement in self: if not statement.name: - context = {'ir_sequence_date': statement.date} - if statement.journal_id.sequence_id: - st_number = statement.journal_id.sequence_id.with_context(**context).next_by_id() - else: - SequenceObj = self.env['ir.sequence'] - st_number = SequenceObj.with_context(**context).next_by_code('account.bank.statement') - statement.name = st_number + statement._set_next_sequence() statement.state = 'open' def button_reopen(self): self.state = 'open' + def _get_last_sequence_domain(self, relaxed=False): + self.ensure_one() + where_string = "WHERE journal_id = %(journal_id)s AND name != '/'" + param = {'journal_id': self.journal_id.id} + + sequence_number_reset = self._deduce_sequence_number_reset(self.search([('date', '<', self.date)], order='date desc', limit=1).name) + if not relaxed: + if sequence_number_reset == 'year': + where_string += " AND date_trunc('year', date) = date_trunc('year', %(date)s) " + param['date'] = self.date + elif sequence_number_reset == 'month': + where_string += " AND date_trunc('month', date) = date_trunc('month', %(date)s) " + param['date'] = self.date + return where_string, param + + def _get_starting_sequence(self): + self.ensure_one() + last_sequence = self._get_last_sequence(relaxed=True) + if last_sequence: + sequence_number_reset = self._deduce_sequence_number_reset(self.search([('date', '<', self.date)], order='date desc', limit=1).name) + if sequence_number_reset == 'year': + sequence = re.match(self._sequence_yearly_regex, last_sequence) + if sequence: + return '%s%04d%s%s%s' % (sequence.group('prefix1'), self.date.year, sequence.group('prefix2'), "0" * len(sequence.group('seq')), sequence.group('suffix')) + elif sequence_number_reset == 'month': + sequence = re.match(self._sequence_monthly_regex, last_sequence) + if sequence: + return '%s%04d%s%02d%s%s%s' % (sequence.group('prefix1'), self.date.year, sequence.group('prefix2'), self.date.month, sequence.group('prefix3'), "0" * len(sequence.group('seq')), sequence.group('suffix')) + + # There was no pattern found, propose one + return "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month) + class AccountBankStatementLine(models.Model): _name = "account.bank.statement.line" diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index d9f67d13b78..7a0d18c2209 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -31,10 +31,22 @@ def calc_check_digits(number): class AccountMove(models.Model): _name = "account.move" - _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin'] + _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'sequence.mixin'] _description = "Journal Entries" _order = 'date desc, name desc, id desc' + @property + def _sequence_monthly_regex(self): + return self.journal_id.sequence_override_regex or super()._sequence_monthly_regex + + @property + def _sequence_yearly_regex(self): + return self.journal_id.sequence_override_regex or super()._sequence_yearly_regex + + @property + def _sequence_fixed_regex(self): + return self.journal_id.sequence_override_regex or super()._sequence_fixed_regex + @api.model def _get_default_journal(self): ''' Get the default journal. @@ -90,7 +102,9 @@ class AccountMove(models.Model): return self.env.company.incoterm_id # ==== Business fields ==== - name = fields.Char(string='Number', required=True, readonly=True, copy=False, default='/') + name = fields.Char(string='Number', copy=False, compute='_compute_name', readonly=False, store=True, index=True, tracking=True) + highest_name = fields.Char(compute='_compute_highest_name') + show_name_warning = fields.Boolean(store=False) date = fields.Date(string='Date', required=True, index=True, readonly=True, states={'draft': [('readonly', False)]}, default=fields.Date.context_today) @@ -102,6 +116,7 @@ class AccountMove(models.Model): ('cancel', 'Cancelled'), ], string='Status', required=True, readonly=True, copy=False, tracking=True, default='draft') + posted_before = fields.Boolean(help="Technical field for knowing if the move has been posted before", copy=False) type = fields.Selection(selection=[ ('entry', 'Journal Entry'), ('out_invoice', 'Customer Invoice'), @@ -243,13 +258,6 @@ class AccountMove(models.Model): readonly=True, states={'draft': [('readonly', False)]}, help='Defines the smallest coinage of the currency that can be used to pay by cash.') - # ==== Fields to set the sequence, on the first invoice of the journal ==== - invoice_sequence_number_next = fields.Char(string='Next Number', - compute='_compute_invoice_sequence_number_next', - inverse='_inverse_invoice_sequence_number_next') - invoice_sequence_number_next_prefix = fields.Char(string='Next Number Prefix', - compute="_compute_invoice_sequence_number_next") - # ==== Display purpose fields ==== invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain', help="Technical field used to have a dynamic domain on journal / taxes in the form view.") @@ -909,6 +917,79 @@ class AccountMove(models.Model): # COMPUTE METHODS # ------------------------------------------------------------------------- + @api.depends('journal_id', 'date', 'state', 'highest_name') + def _compute_name(self): + for record in self.sorted(lambda m: (m.date, m.ref or '', m.id)): + if not record.name or record.name == '/': + if record.state == 'draft' and not record.posted_before and not record.highest_name: + # First name of the period for the journal, no name yet + record._set_next_sequence() + elif record.state == 'posted': + # No name yet but has been posted + record._set_next_sequence() + if record.name and record.state == 'draft' and not record.posted_before and record.highest_name: + # Not the first name of the period for the journal, but had a name set + record.name = '/' + record.name = record.name or '/' + + @api.depends('journal_id', 'date', 'state') + def _compute_highest_name(self): + for record in self: + record.highest_name = record._get_last_sequence() + + @api.onchange('name', 'highest_name') + def _onchange_name_warning(self): + if self.name and self.name != '/' and self.name <= (self.highest_name or ''): + self.show_name_warning = True + else: + self.show_name_warning = False + + def _get_last_sequence_domain(self, relaxed=False): + self.ensure_one() + if not self.date or not self.journal_id: + return "WHERE FALSE", {} + where_string = "WHERE journal_id = %(journal_id)s AND name != '/'" + param = {'journal_id': self.journal_id.id} + + if not relaxed: + reference_move = self.search([('journal_id', '=', self.journal_id.id), ('date', '<=', self.date), ('id', '!=', self.id or self._origin.id)], order='date desc', limit=1) or self.search([('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id)], order='date asc', limit=1) + sequence_number_reset = self._deduce_sequence_number_reset(reference_move.name) + if sequence_number_reset == 'year': + where_string += " AND date_trunc('year', date) = date_trunc('year', %(date)s) " + param['date'] = self.date + elif sequence_number_reset == 'month': + where_string += " AND date_trunc('month', date) = date_trunc('month', %(date)s) " + param['date'] = self.date + + if self.journal_id.refund_sequence: + if self.type in ('out_refund', 'in_refund'): + where_string += " AND type IN ('out_refund', 'in_refund') " + else: + where_string += " AND type NOT IN ('out_refund', 'in_refund') " + + return where_string, param + + def _get_starting_sequence(self): + self.ensure_one() + # Try to find a pattern already used by relaxing a domain. If we are here, the domain non relaxed should return nothing. + last_sequence = self._get_last_sequence(relaxed=True) + if last_sequence: + reference_move = self.search([('journal_id', '=', self.journal_id.id), ('date', '<=', self.date), ('id', '!=', self.id or self._origin.id)], order='date asc', limit=1) or self.search([('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id)], order='date desc', limit=1) + sequence_number_reset = self._deduce_sequence_number_reset(reference_move.name) + if sequence_number_reset == 'year': + sequence = re.match(self._sequence_yearly_regex, last_sequence) + if sequence: + return '%s%04d%s%s%s' % (sequence.group('prefix1'), self.date.year, sequence.group('prefix2'), "0" * len(sequence.group('seq')), sequence.group('suffix')) + elif sequence_number_reset == 'month': + sequence = re.match(self._sequence_monthly_regex, last_sequence) + if sequence: + return '%s%04d%s%02d%s%s%s' % (sequence.group('prefix1'), self.date.year, sequence.group('prefix2'), self.date.month, sequence.group('prefix3'), "0" * len(sequence.group('seq')), sequence.group('suffix')) + + starting_sequence = "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month) + if self.journal_id.refund_sequence and self.type in ('out_refund', 'in_refund'): + starting_sequence = "R" + starting_sequence + return starting_sequence + @api.depends('type') def _compute_type_name(self): type_name_mapping = {k: v for k, v in @@ -1128,69 +1209,6 @@ class AccountMove(models.Model): vendor_display_name = _('#Created by: %s') % (move.sudo().create_uid.name or self.env.user.name) move.invoice_partner_display_name = vendor_display_name - @api.depends('state', 'journal_id', 'date', 'invoice_date') - def _compute_invoice_sequence_number_next(self): - """ computes the prefix of the number that will be assigned to the first invoice/bill/refund of a journal, in order to - let the user manually change it. - """ - # Check user group. - system_user = self.env.is_system() - if not system_user: - self.invoice_sequence_number_next_prefix = False - self.invoice_sequence_number_next = False - return - - # Check moves being candidates to set a custom number next. - moves = self.filtered(lambda move: move.is_invoice() and move.name == '/') - if not moves: - self.invoice_sequence_number_next_prefix = False - self.invoice_sequence_number_next = False - return - - treated = self.browse() - for key, group in groupby(moves, key=lambda move: (move.journal_id, move._get_sequence())): - journal, sequence = key - domain = [('journal_id', '=', journal.id), ('state', '=', 'posted')] - if self.ids: - domain.append(('id', 'not in', self.ids)) - if journal.type == 'sale': - domain.append(('type', 'in', ('out_invoice', 'out_refund'))) - elif journal.type == 'purchase': - domain.append(('type', 'in', ('in_invoice', 'in_refund'))) - else: - continue - if self.search_count(domain): - continue - - for move in group: - sequence_date = move.date or move.invoice_date - prefix, dummy = sequence._get_prefix_suffix(date=sequence_date, date_range=sequence_date) - number_next = sequence._get_current_sequence(sequence_date=sequence_date).number_next_actual - move.invoice_sequence_number_next_prefix = prefix - move.invoice_sequence_number_next = '%%0%sd' % sequence.padding % number_next - treated |= move - remaining = (self - treated) - remaining.invoice_sequence_number_next_prefix = False - remaining.invoice_sequence_number_next = False - - def _inverse_invoice_sequence_number_next(self): - ''' Set the number_next on the sequence related to the invoice/bill/refund''' - # Check user group. - if not self.env.is_admin(): - return - - # Set the next number in the sequence. - for move in self: - if not move.invoice_sequence_number_next: - continue - sequence = move._get_sequence() - nxt = re.sub("[^0-9]", '', move.invoice_sequence_number_next) - result = re.match("(0*)([0-9]+)", nxt) - if result and sequence: - sequence_date = move.date or move.invoice_date - date_sequence = sequence._get_current_sequence(sequence_date=sequence_date) - date_sequence.number_next_actual = int(result.group(2)) - def _compute_payments_widget_to_reconcile_info(self): for move in self: move.invoice_outstanding_credits_debits_widget = json.dumps(False) @@ -1367,7 +1385,7 @@ class AccountMove(models.Model): # /!\ Computed stored fields are not yet inside the database. self._cr.execute(''' - SELECT move2.id + SELECT move2.id, move2.name FROM account_move move INNER JOIN account_move move2 ON move2.name = move.name @@ -1376,9 +1394,10 @@ class AccountMove(models.Model): AND move2.id != move.id WHERE move.id IN %s AND move2.state = 'posted' ''', [tuple(moves.ids)]) - res = self._cr.fetchone() + res = self._cr.fetchall() if res: - raise ValidationError(_('Posted journal entry must have an unique sequence number per company.')) + raise ValidationError(_('Posted journal entry must have an unique sequence number per company.\n' + 'Problematic numbers: %s\n') % ', '.join(r[1] for r in res)) @api.constrains('ref', 'type', 'partner_id', 'journal_id', 'invoice_date') def _check_duplicate_supplier_reference(self): @@ -1587,7 +1606,7 @@ class AccountMove(models.Model): raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_MOVE_FIELDS)) if (move.restrict_mode_hash_table and move.inalterable_hash and 'inalterable_hash' in vals) or (move.secure_sequence_number and 'secure_sequence_number' in vals): raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.')) - if (move.name != '/' and 'journal_id' in vals and move.journal_id.id != vals['journal_id']): + if (move.posted_before and 'journal_id' in vals and move.journal_id.id != vals['journal_id']): raise UserError(_('You cannot edit the journal of an account move if it has been posted once.')) # You can't change the date of a move being inside a locked period. @@ -1600,6 +1619,11 @@ class AccountMove(models.Model): move._check_fiscalyear_lock_date() move.line_ids._check_tax_lock_date() + if move.journal_id.sequence_override_regex and vals.get('name') and vals['name'] != '/' and not re.match(move.journal_id.sequence_override_regex, vals['name']): + if not self.env.user.has_group('account.group_account_manager'): + raise UserError(_('The Journal Entry sequence is not conform to the current format. Only the Advisor can change it.')) + move.journal_id.sequence_override_regex = False + if self._move_autocomplete_invoice_lines_write(vals): res = True else: @@ -1612,8 +1636,8 @@ class AccountMove(models.Model): self._check_fiscalyear_lock_date() self.mapped('line_ids')._check_tax_lock_date() - if ('state' in vals and vals.get('state') == 'posted') and self.restrict_mode_hash_table: - for move in self.filtered(lambda m: not(m.secure_sequence_number or m.inalterable_hash)): + if ('state' in vals and vals.get('state') == 'posted'): + for move in self.filtered(lambda m: m.restrict_mode_hash_table and not(m.secure_sequence_number or m.inalterable_hash)): new_number = move.journal_id.secure_sequence_id.next_by_id() vals_hashing = {'secure_sequence_number': new_number, 'inalterable_hash': move._get_new_hash(new_number)} @@ -1627,7 +1651,7 @@ class AccountMove(models.Model): def unlink(self): for move in self: - if move.name != '/' and not self._context.get('force_delete'): + if move.posted_before and not self._context.get('force_delete'): raise UserError(_("You cannot delete an entry which has been posted once.")) move.line_ids.unlink() return super(AccountMove, self).unlink() @@ -1782,19 +1806,6 @@ class AccountMove(models.Model): else: raise UserError(_('The combination of reference model and reference type on the journal is not implemented')) - def _get_sequence(self): - ''' Return the sequence to be used during the post of the current move. - :return: An ir.sequence record or False. - ''' - self.ensure_one() - - journal = self.journal_id - if self.type in ('entry', 'out_invoice', 'in_invoice', 'out_receipt', 'in_receipt') or not journal.refund_sequence: - return journal.sequence_id - if not journal.refund_sequence_id: - return - return journal.refund_sequence_id - def _get_move_display_name(self, show_ref=False): ''' Helper to get the display name of an invoice depending of its type. :param show_ref: A flag indicating of the display name must include or not the journal entry reference. @@ -2082,7 +2093,7 @@ class AccountMove(models.Model): raise UserError(_('You need to add a line before posting.')) if move.auto_post and move.date > fields.Date.today(): date_msg = move.date.strftime(get_lang(self.env).date_format) - raise UserError(_("This move is configured to be auto-posted on %s" % date_msg)) + raise UserError(_("This move is configured to be auto-posted on %s") % date_msg) if not move.partner_id: if move.is_sale_document(): @@ -2110,25 +2121,14 @@ class AccountMove(models.Model): # Create the analytic lines in batch is faster as it leads to less cache invalidation. self.mapped('line_ids').create_analytic_lines() + self.state = 'posted' + self.posted_before = True for move in self: if move.auto_post and move.date > fields.Date.today(): raise UserError(_("This move is configured to be auto-posted on {}".format(move.date.strftime(get_lang(self.env).date_format)))) move.message_subscribe([p.id for p in [move.partner_id] if p not in move.sudo().message_partner_ids]) - to_write = {'state': 'posted'} - - if move.name == '/': - # Get the journal's sequence. - sequence = move._get_sequence() - if not sequence: - raise UserError(_('Please define a sequence on your journal.')) - - # Consume a new number. - to_write['name'] = sequence.next_by_id(sequence_date=move.date) - - move.write(to_write) - # Compute 'ref' for 'out_invoice'. if move._auto_compute_invoice_reference(): to_write = { @@ -2147,7 +2147,8 @@ class AccountMove(models.Model): move.company_id.account_bank_reconciliation_start = move.date for move in self: - if not move.partner_id: continue + if not move.partner_id: + continue if move.type.startswith('out_'): move.partner_id._increase_rank('customer_rank') elif move.type.startswith('in_'): @@ -3321,13 +3322,13 @@ class AccountMoveLine(models.Model): if account_to_write and account_to_write.deprecated: raise UserError(_('You cannot use a deprecated account.')) - # when making a reconciliation on an existing liquidity journal item, mark the payment as reconciled for line in self: if line.parent_state == 'posted': if line.move_id.restrict_mode_hash_table and set(vals).intersection(INTEGRITY_HASH_LINE_FIELDS): raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_LINE_FIELDS)) if any(key in vals for key in ('tax_ids', 'tax_line_ids')): raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.')) + # When making a reconciliation on an existing liquidity journal item, mark the payment as reconciled if 'statement_line_id' in vals and line.payment_id: # In case of an internal transfer, there are 2 liquidity move lines to match with a bank statement if all(line.statement_id for line in line.payment_id.move_line_ids.filtered( @@ -3370,7 +3371,7 @@ class AccountMoveLine(models.Model): # Get initial values for each line move_initial_values = {} - for line in self.filtered(lambda l: l.move_id.name != '/'): # Only lines with posted once move. + for line in self.filtered(lambda l: l.move_id.posted_before): # Only lines with posted once move. for field in tracking_fields: # Group initial values by move_id if line.move_id.id not in move_initial_values: @@ -4398,6 +4399,7 @@ class AccountPartialReconcile(models.Model): # recorded before the period lock date as the tax statement for this period is # probably already sent to the estate. newly_created_move.write({'date': move_date}) + newly_created_move.recompute(['name']) # post move newly_created_move.post() diff --git a/addons/account/models/sequence_mixin.py b/addons/account/models/sequence_mixin.py new file mode 100644 index 00000000000..ca0c9cde80d --- /dev/null +++ b/addons/account/models/sequence_mixin.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +import re + + +class SequenceMixin(models.AbstractModel): + """Mechanism used to have an editable sequence number. + + Be careful of how you use this regarding the prefixes. More info in the + docstring of _get_last_sequence. + """ + + _name = 'sequence.mixin' + _description = "Automatic sequence" + + _sequence_field = "name" + _sequence_monthly_regex = r'^(?P<prefix1>.*?)(?P<year>\d{4})(?P<prefix2>\D*?)(?P<month>\d{2})(?P<prefix3>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$' + _sequence_yearly_regex = r'^(?P<prefix1>.*?)(?P<year>\d{4})(?P<prefix2>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$' + _sequence_fixed_regex = r'^(?P<prefix1>.*?)(?P<seq>\d*)(?P<suffix>\D*?)$' + + @api.model + def _deduce_sequence_number_reset(self, name): + """Detect if the used sequence resets yearly, montly or never. + + :param name: the sequence that is used as a reference to detect the resetting + periodicity. Typically, it is the last before the one you want to give a + sequence. + """ + def _check_grouping(grouping, optional=None, required=None): + sequence_dict = sequence.groupdict() + return all(key in sequence_dict for key in (optional or [])) and all(sequence_dict.get(key) for key in (required or [])) + + if not name: + return False + sequence = re.match(self._sequence_monthly_regex, name) + if sequence and _check_grouping(sequence, ['prefix1', 'prefix2', 'prefix3', 'seq', 'suffix'], ['year', 'month']) and 2000 <= int(sequence.group('year')) <= 2100 and 0 < int(sequence.group('month')) <= 12: + return 'month' + sequence = re.match(self._sequence_yearly_regex, name) + if sequence and _check_grouping(sequence, ['prefix1', 'prefix2', 'seq', 'suffix'], ['year']) and 2000 <= int(sequence.group('year')) <= 2100: + return 'year' + sequence = re.match(self._sequence_fixed_regex, name) + if sequence and _check_grouping(sequence, ['prefix1', 'seq', 'suffix']): + return 'never' + raise ValidationError(_('The sequence regex should at least contain the prefix1, seq and suffix grouping keys. For instance:\n^(?P<prefix1>.*?)(?P<seq>\d*)(?P<suffix>\D*?)$')) + + def _get_last_sequence_domain(self, relaxed=False): + """Get the sql domain to retreive the previous sequence number. + + This function should be overriden by models heriting from this mixin. + + :param relaxed: see _get_last_sequence. + + :returns: tuple(where_string, where_params): with + where_string: the entire SQL WHERE clause as a string. + where_params: a dictionary containing the parameters to substitute + at the execution of the query. + """ + self.ensure_one() + return "", {} + + def _get_starting_sequence(self): + """Get a default sequence number. + + This function should be overriden by models heriting from this mixin + This number will be incremented so you probably want to start the sequence at 0. + + :return: string to use as the default sequence to increment + """ + self.ensure_one() + return "00000000" + + def _get_last_sequence(self, relaxed=False): + """Retrieve the previous sequence. + + This is done by taking the number with the greatest alphabetical value within + the domain of _get_last_sequence_domain. This means that the prefix has a + huge importance. + For instance, if you have INV/2019/0001 and INV/2019/0002, when you rename the + last one to FACT/2019/0001, one might expect the next number to be + FACT/2019/0002 but it will be INV/2019/0002 (again) because INV > FACT. + Therefore, changing the prefix might not be convenient during a period, and + would only work when the numbering makes a new start (domain returns by + _get_last_sequence_domain is [], i.e: a new year). + + :param field_name: the field that contains the sequence. + :param relaxed: this should be set to True when a previous request didn't find + something without. This allows to find a pattern from a previous period, and + try to adapt it for the new period. + + :return: the string of the previous sequence or None if there wasn't any. + """ + self.ensure_one() + if self._sequence_field not in self._fields or not self._fields[self._sequence_field].store: + raise ValidationError(_('%s is not a stored field') % self._sequence_field) + where_string, param = self._get_last_sequence_domain(relaxed) + if self.id or self.id.origin: + where_string += " AND id != %(id)s " + param['id'] = self.id or self.id.origin + query = "SELECT {field} FROM {table} {where_string} ORDER BY {field} DESC LIMIT 1 FOR UPDATE".format(table=self._table, where_string=where_string, field=self._sequence_field) + self.flush([self._sequence_field]) + self.env.cr.execute(query, param) + return (self.env.cr.fetchone() or [None])[0] + + def _set_next_sequence(self): + """Set the next sequence. + + This method ensures that the field is set both in the ORM and in the database. + This is necessary because we use a database query to get the previous sequence, + and we need that query to always be executed on the latest data. + + :param field_name: the field that contains the sequence. + """ + self.ensure_one() + last_sequence = self._get_last_sequence() or self._get_starting_sequence() + + sequence = re.match(self._sequence_fixed_regex, last_sequence) + value = ("{prefix}{seq:0%sd}{suffix}" % len(sequence.group('seq'))).format( + prefix=sequence.group('prefix1'), + seq=int(sequence.group('seq') or 0) + 1, + suffix=sequence.group('suffix'), + ) + self[self._sequence_field] = value + self.flush([self._sequence_field]) |
