diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 6572c12b3a4..c636ac23fc6 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -27,6 +27,7 @@ You could use this simplified accounting in case you work with an (external) acc 'wizard/account_accrual_accounting_view.xml', 'wizard/account_unreconcile_view.xml', 'wizard/account_move_reversal_view.xml', + 'wizard/account_resequence_views.xml', 'views/account_move_views.xml', 'wizard/setup_wizards_view.xml', 'wizard/pos_box.xml', @@ -70,6 +71,7 @@ You could use this simplified accounting in case you work with an (external) acc ], 'qweb': [ "static/src/xml/account_payment.xml", + 'static/src/xml/account_resequence.xml', "static/src/xml/account_report_backend.xml", "static/src/xml/bills_tree_upload_views.xml", 'static/src/xml/account_journal_activity.xml', 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.*?)(?P\d{4})(?P\D*?)(?P\d{2})(?P\D+?)(?P\d+)(?P\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')]) @@ -1252,33 +1183,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): ''' @@ -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.*?)(?P\d{4})(?P\D*?)(?P\d{2})(?P\D+?)(?P\d*)(?P\D*?)$' + _sequence_yearly_regex = r'^(?P.*?)(?P\d{4})(?P\D+?)(?P\d*)(?P\D*?)$' + _sequence_fixed_regex = r'^(?P.*?)(?P\d*)(?P\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.*?)(?P\d*)(?P\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]) diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 90962c9c44a..73f2f74d6ac 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -128,6 +128,7 @@ access_account_payment_register,access.account.payment.register,model_account_pa access_account_bank_statement_closebalance,access.account.bank.statement.closebalance,model_account_bank_statement_closebalance,account.group_account_user,1,1,1,0 access_account_accrual_accounting_wizard,access.account.accrual.accounting.wizard,model_account_accrual_accounting_wizard,account.group_account_user,1,1,1,0 access_account_unreconcile,access.account.unreconcile,model_account_unreconcile,account.group_account_user,1,1,1,0 +access_account_resequence,access.account.resequence.wizard,model_account_resequence_wizard,account.group_account_user,1,1,1,0 access_validate_account_move,access.validate.account.move,model_validate_account_move,account.group_account_invoice,1,1,1,0 access_cash_box_out,access.cash.box.out,model_cash_box_out,account.group_account_user,1,1,1,0 access_account_move_reversal,access.account.move.reversal,model_account_move_reversal,account.group_account_invoice,1,1,1,0 diff --git a/addons/account/static/src/js/account_resequence_field.js b/addons/account/static/src/js/account_resequence_field.js new file mode 100644 index 00000000000..0f918e9a297 --- /dev/null +++ b/addons/account/static/src/js/account_resequence_field.js @@ -0,0 +1,32 @@ +odoo.define('account.ShowResequenceRenderer', function (require) { +"use strict"; + +const { Component } = owl; +const { useState } = owl.hooks; +const AbstractFieldOwl = require('web.AbstractFieldOwl'); +const field_registry = require('web.field_registry'); + +class ChangeLine extends Component { } +ChangeLine.template = 'account.ResequenceChangeLine'; +ChangeLine.props = ["changeLine", 'ordering']; + + +class ShowResequenceRenderer extends AbstractFieldOwl { + constructor(...args) { + super(...args); + this.data = this.value ? JSON.parse(this.value) : { + changeLines: [], + ordering: 'date', + }; + } + async willUpdateProps(nextProps) { + await super.willUpdateProps(nextProps); + Object.assign(this.data, JSON.parse(this.value)); + } +} +ShowResequenceRenderer.template = 'account.ResequenceRenderer'; +ShowResequenceRenderer.components = { ChangeLine } + +field_registry.add('account_resequence_widget', ShowResequenceRenderer); +return ShowResequenceRenderer; +}); diff --git a/addons/account/static/src/scss/variables.scss b/addons/account/static/src/scss/variables.scss index 72a0a315181..3e79231cecf 100644 --- a/addons/account/static/src/scss/variables.scss +++ b/addons/account/static/src/scss/variables.scss @@ -3,3 +3,17 @@ $o-account-main-table-borders-padding: 3px; $o-account-light-border: 1px solid #bbb; $o-account-initial-line-background: #f0f0f0; $o-account-info-color: #44c; + + +@keyframes animate-red { + 0% { + color: red; + } + 100% { + color: inherit; + } +} + +.animate { + animation: animate-red 1s ease; +} diff --git a/addons/account/static/src/xml/account_resequence.xml b/addons/account/static/src/xml/account_resequence.xml new file mode 100644 index 00000000000..59ea77e5fbd --- /dev/null +++ b/addons/account/static/src/xml/account_resequence.xml @@ -0,0 +1,25 @@ + + + +
+ + + + + + + + + +
DateBeforeAfter
+
+ + + + + + + + + +
diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 2678e6524c2..8a608c9c407 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -6,6 +6,8 @@ from odoo import fields from odoo.exceptions import ValidationError, UserError from dateutil.relativedelta import relativedelta +from functools import reduce +import json @tagged('post_install', '-at_install') @@ -320,18 +322,155 @@ class TestAccountMove(AccountTestInvoicingCommon): # You can remove journal items if the related journal entry is still balanced. self.test_move.line_ids.unlink() - def test_misc_unique_sequence_number(self): - ''' Ensure two journal entries can't share the same name when using the same sequence. ''' + def test_journal_sequence(self): + self.assertEqual(self.test_move.name, 'MISC/2016/01/0001') self.test_move.post() + self.assertEqual(self.test_move.name, 'MISC/2016/01/0001') - # Edit the sequence to force the next move to get the same name. - self.test_move.journal_id\ - .sequence_id.date_range_ids\ - .filtered(lambda seq: seq.date_from == fields.Date.from_string('2016-01-01')).number_next -= 1 + copy1 = self.test_move.copy() + self.assertEqual(copy1.name, '/') + copy1.post() + self.assertEqual(copy1.name, 'MISC/2016/01/0002') - test_move2 = self.test_move.copy() + copy2 = self.test_move.copy() + new_journal = self.test_move.journal_id.copy() + new_journal.code = "MISC2" + copy2.journal_id = new_journal + self.assertEqual(copy2.name, 'MISC2/2016/01/0001') + with Form(copy2) as move_form: # It is editable in the form + move_form.name = 'MyMISC/2099/0001' + copy2.post() + self.assertEqual(copy2.name, 'MyMISC/2099/0001') + + copy3 = copy2.copy() + self.assertEqual(copy3.name, '/') + with self.assertRaises(AssertionError): + with Form(copy2) as move_form: # It is not editable in the form + move_form.name = 'MyMISC/2099/0002' + copy3.post() + self.assertEqual(copy3.name, 'MyMISC/2099/0002') + copy3.name = 'MISC2/2016/00002' + + copy4 = copy2.copy() + copy4.post() + self.assertEqual(copy4.name, 'MyMISC/2099/0002') + + copy5 = copy2.copy() + copy5.date = '2021-02-02' + copy5.post() + self.assertEqual(copy5.name, 'MyMISC/2021/0001') + copy5.name = 'N\'importe quoi?' + + copy6 = copy5.copy() + copy6.post() + self.assertEqual(copy6.name, '1N\'importe quoi?') + + def test_journal_sequence_format(self): + """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'), + ('1234567', '1234568', '1234569', '1234570'), + ('20190910', '20190911', '20190912', '20190913'), + ('2019-0910', '2019-0911', '2019-0912', '2017-0001'), + ('201909-10', '201909-11', '201604-01', '201703-01'), + ('JRNL/2016/00001suffix', 'JRNL/2016/00002suffix', 'JRNL/2016/00003suffix', 'JRNL/2017/00001suffix'), + ] + other_moves = self.env['account.move'].search([('journal_id', '=', self.test_move.journal_id.id)]) - self.test_move + other_moves.unlink() # Do not interfere when trying to get the highest name for new periods + + init_move = self.test_move + next_move = init_move.copy() + next_move_month = init_move.copy() + next_move_year = init_move.copy() + init_move.date = '2016-03-12' + next_move.date = '2016-03-12' + next_move_month.date = '2016-04-12' + next_move_year.date = '2017-03-12' + next_moves = (next_move + next_move_month + next_move_year) + next_moves.post() + + for sequence_init, sequence_next, sequence_next_month, sequence_next_year in sequences: + init_move.name = sequence_init + next_moves.name = False + next_moves._compute_name() + self.assertEqual(next_move.name, sequence_next) + self.assertEqual(next_move_month.name, sequence_next_month) + self.assertEqual(next_move_year.name, sequence_next_year) + + def test_journal_override_sequence_regex(self): + other_moves = self.env['account.move'].search([('journal_id', '=', self.test_move.journal_id.id)]) - self.test_move + other_moves.unlink() # Do not interfere when trying to get the highest name for new periods + self.test_move.name = '00000876-G 0002' + next = self.test_move.copy() + next.post() + self.assertEqual(next.name, '00000876-G 0003') # Wait, I didn't want this! + + next.journal_id.sequence_override_regex = r'^(?P)(?P\d*)(?P.*)$' + next.name = '/' + next._compute_name() + self.assertEqual(next.name, '00000877-G 0002') # Pfew, better! + + def test_journal_sequence_ordering(self): + self.test_move.name = 'XMISC/2016/00001' + copies = reduce((lambda x, y: x+y), [self.test_move.copy() for i in range(6)]) + + copies[0].date = '2019-03-05' + copies[1].date = '2019-03-06' + copies[2].date = '2019-03-07' + copies[3].date = '2019-03-04' + copies[4].date = '2019-03-05' + copies[5].date = '2019-03-05' + # that entry is actualy the first one of the period, so it already has a name + # set it to '/' so that it is recomputed at post to be ordered correctly. + copies[0].name = '/' + copies.post() + + # Ordered by date + self.assertEqual(copies[0].name, 'XMISC/2019/00002') + self.assertEqual(copies[1].name, 'XMISC/2019/00005') + self.assertEqual(copies[2].name, 'XMISC/2019/00006') + self.assertEqual(copies[3].name, 'XMISC/2019/00001') + self.assertEqual(copies[4].name, 'XMISC/2019/00003') + self.assertEqual(copies[5].name, 'XMISC/2019/00004') + + # Can't have twice the same name with self.assertRaises(ValidationError): - test_move2.post() + copies[0].name = 'XMISC/2019/00001' + + # Lets remove the order by date + copies[0].name = 'XMISC/2019/10001' + copies[1].name = 'XMISC/2019/10002' + copies[2].name = 'XMISC/2019/10003' + copies[3].name = 'XMISC/2019/10004' + copies[4].name = 'XMISC/2019/10005' + copies[5].name = 'XMISC/2019/10006' + + copies[4].with_context(force_delete=True).unlink() + copies[5].button_draft() + + wizard = Form(self.env['account.resequence.wizard'].with_context(active_ids=set(copies.ids) - set(copies[4].ids), active_model='account.move')) + + new_values = json.loads(wizard.new_values) + self.assertEqual(new_values[str(copies[0].id)]['new_by_date'], 'XMISC/2019/10002') + self.assertEqual(new_values[str(copies[0].id)]['new_by_name'], 'XMISC/2019/10001') + + self.assertEqual(new_values[str(copies[1].id)]['new_by_date'], 'XMISC/2019/10004') + self.assertEqual(new_values[str(copies[1].id)]['new_by_name'], 'XMISC/2019/10002') + + self.assertEqual(new_values[str(copies[2].id)]['new_by_date'], 'XMISC/2019/10005') + self.assertEqual(new_values[str(copies[2].id)]['new_by_name'], 'XMISC/2019/10003') + + self.assertEqual(new_values[str(copies[3].id)]['new_by_date'], 'XMISC/2019/10001') + self.assertEqual(new_values[str(copies[3].id)]['new_by_name'], 'XMISC/2019/10004') + + self.assertEqual(new_values[str(copies[5].id)]['new_by_date'], 'XMISC/2019/10003') + self.assertEqual(new_values[str(copies[5].id)]['new_by_name'], 'XMISC/2019/10005') + + wizard.save().resequence() + + self.assertEqual(copies[3].state, 'posted') + self.assertEqual(copies[5].name, 'XMISC/2019/10005') + self.assertEqual(copies[5].state, 'draft') def test_add_followers_on_post(self): # Add some existing partners, some from another company diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index 1a0f8d27a5f..324f75b844a 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -1092,33 +1092,6 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): 'amount_total': 208.01, }) - def test_in_invoice_line_onchange_sequence_number_1(self): - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0001', - 'invoice_sequence_number_next_prefix': 'BILL/2019/', - }]) - - move_form = Form(self.invoice) - move_form.invoice_sequence_number_next = '0042' - move_form.save() - - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0042', - 'invoice_sequence_number_next_prefix': 'BILL/2019/', - }]) - - self.invoice.post() - - self.assertRecordValues(self.invoice, [{'name': 'BILL/2019/0042'}]) - - values = { - 'invoice_date': self.invoice.invoice_date, - } - invoice_copy = self.invoice.copy(default=values) - invoice_copy.post() - - self.assertRecordValues(invoice_copy, [{'name': 'BILL/2019/0043'}]) - def test_in_invoice_onchange_past_invoice_1(self): copy_invoice = self.invoice.copy() diff --git a/addons/account/tests/test_account_move_in_refund.py b/addons/account/tests/test_account_move_in_refund.py index 8bb1c5b8335..897ee41b4be 100644 --- a/addons/account/tests/test_account_move_in_refund.py +++ b/addons/account/tests/test_account_move_in_refund.py @@ -804,33 +804,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): 'amount_total': 208.01, }) - def test_in_refund_line_onchange_sequence_number_1(self): - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0001', - 'invoice_sequence_number_next_prefix': 'RBILL/2019/', - }]) - - move_form = Form(self.invoice) - move_form.invoice_sequence_number_next = '0042' - move_form.save() - - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0042', - 'invoice_sequence_number_next_prefix': 'RBILL/2019/', - }]) - - self.invoice.post() - - self.assertRecordValues(self.invoice, [{'name': 'RBILL/2019/0042'}]) - - values = { - 'invoice_date': self.invoice.invoice_date, - } - invoice_copy = self.invoice.copy(default=values) - invoice_copy.post() - - self.assertRecordValues(invoice_copy, [{'name': 'RBILL/2019/0043'}]) - def test_in_refund_onchange_past_invoice_1(self): copy_invoice = self.invoice.copy() diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index 34277fa494c..2eb0b43a2da 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -1501,33 +1501,6 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): 'amount_total': 260.01, }) - def test_out_invoice_onchange_sequence_number_1(self): - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0001', - 'invoice_sequence_number_next_prefix': 'INV/2019/', - }]) - - move_form = Form(self.invoice) - move_form.invoice_sequence_number_next = '0042' - move_form.save() - - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0042', - 'invoice_sequence_number_next_prefix': 'INV/2019/', - }]) - - self.invoice.post() - - self.assertRecordValues(self.invoice, [{'name': 'INV/2019/0042'}]) - - values = { - 'invoice_date': self.invoice.invoice_date, - } - invoice_copy = self.invoice.copy(default=values) - invoice_copy.post() - - self.assertRecordValues(invoice_copy, [{'name': 'INV/2019/0043'}]) - def test_out_invoice_create_refund(self): self.invoice.post() @@ -1571,7 +1544,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'invoice_payment_term_id': None, - 'name': '/', + 'name': 'RINV/2019/02/0001', 'date': move_reversal.date, 'state': 'draft', 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), @@ -2335,7 +2308,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): { **self.term_line_vals_1, 'currency_id': self.currency_data['currency'].id, - 'name': 'INV/2017/0001', + 'name': 'INV/2017/01/0001', 'amount_currency': 1410.0, 'debit': 705.0, 'credit': 0.0, @@ -2345,7 +2318,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): **self.move_vals, 'currency_id': self.currency_data['currency'].id, 'date': fields.Date.from_string('2017-01-01'), - 'invoice_payment_ref': 'INV/2017/0001', + 'invoice_payment_ref': 'INV/2017/01/0001', }) accrual_lines = move.invoice_line_ids.mapped('matched_debit_ids.debit_move_id.move_id.line_ids').sorted('date') diff --git a/addons/account/tests/test_account_move_out_refund.py b/addons/account/tests/test_account_move_out_refund.py index 05892ef4184..e7f7a66df37 100644 --- a/addons/account/tests/test_account_move_out_refund.py +++ b/addons/account/tests/test_account_move_out_refund.py @@ -804,33 +804,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): 'amount_total': 260.01, }) - def test_out_refund_line_onchange_sequence_number_1(self): - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0001', - 'invoice_sequence_number_next_prefix': 'RINV/2019/', - }]) - - move_form = Form(self.invoice) - move_form.invoice_sequence_number_next = '0042' - move_form.save() - - self.assertRecordValues(self.invoice, [{ - 'invoice_sequence_number_next': '0042', - 'invoice_sequence_number_next_prefix': 'RINV/2019/', - }]) - - self.invoice.post() - - self.assertRecordValues(self.invoice, [{'name': 'RINV/2019/0042'}]) - - values = { - 'invoice_date': self.invoice.invoice_date, - } - invoice_copy = self.invoice.copy(default=values) - invoice_copy.post() - - self.assertRecordValues(invoice_copy, [{'name': 'RINV/2019/0043'}]) - def test_out_refund_create_1(self): # Test creating an account_move with the least information. move = self.env['account.move'].create({ diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index 07f56b2ae7c..639153fbf47 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -22,7 +22,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): cls.invoice_line_1 = cls._create_invoice_line(100, cls.partner_1, 'out_invoice') cls.invoice_line_2 = cls._create_invoice_line(200, cls.partner_1, 'out_invoice') cls.invoice_line_3 = cls._create_invoice_line(300, cls.partner_1, 'in_refund') - cls.invoice_line_3.move_id.name = "RBILL/2018/0013" # Without demo data, avoid to match with the first invoice + cls.invoice_line_3.move_id.name = "RBILL/2019/09/0013" # Without demo data, avoid to match with the first invoice cls.invoice_line_4 = cls._create_invoice_line(1000, cls.partner_2, 'in_invoice') current_assets_account = cls.env['account.account'].search([ @@ -65,7 +65,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): }) cls.bank_line_1 = cls.env['account.bank.statement.line'].create({ 'statement_id': cls.bank_st.id, - 'name': 'invoice %s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2]), + 'name': 'invoice %s-%s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2], invoice_number.split('/')[3]), 'partner_id': cls.partner_1.id, 'amount': '100', 'sequence': 1, @@ -99,8 +99,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): @classmethod def _create_invoice_line(cls, amount, partner, type): ''' Create an invoice on the fly.''' - invoice_form = Form(cls.env['account.move'].with_context(default_type=type)) - invoice_form.invoice_date = fields.Date.from_string('2019-09-01') + invoice_form = Form(cls.env['account.move'].with_context(default_type=type, default_invoice_date='2019-09-01', default_date='2019-09-01')) invoice_form.partner_id = partner with invoice_form.invoice_line_ids.new() as invoice_line_form: invoice_line_form.name = 'xxxx' diff --git a/addons/account/views/account.xml b/addons/account/views/account.xml index 7c5e9910137..93f9464ce70 100644 --- a/addons/account/views/account.xml +++ b/addons/account/views/account.xml @@ -20,6 +20,7 @@ + diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 4dc40505b3f..7d5116f93fc 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -561,6 +561,8 @@ + + @@ -570,37 +572,23 @@ - - -
+
- +

- Draft Invoice - Draft Credit Note - Draft Bill - Draft Refund - Draft Sales Receipt - Draft Purchase Receipt + Draft

- - First Number: - -

- + The current highest number is . You might want to put a higher number here. +

+

- -
- - -

@@ -657,7 +645,7 @@ groups="account.group_account_readonly" options="{'no_create': True}" domain="[('type', '=?', invoice_filter_type_domain)]" - attrs="{'readonly': [('name', '!=', '/')]}"/> + attrs="{'readonly': [('posted_before', '=', True)]}"/> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 69982c35385..0bd619d3847 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -285,22 +285,7 @@ - diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py index f213b3126f8..9be1a3ef8d1 100644 --- a/addons/account/wizard/__init__.py +++ b/addons/account/wizard/__init__.py @@ -10,6 +10,7 @@ from . import account_move_reversal from . import account_report_common from . import account_report_common_journal from . import account_report_print_journal +from . import account_resequence from . import setup_wizards from . import wizard_tax_adjustments from . import account_invoice_send diff --git a/addons/account/wizard/account_resequence.py b/addons/account/wizard/account_resequence.py new file mode 100644 index 00000000000..9029209c3b8 --- /dev/null +++ b/addons/account/wizard/account_resequence.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.date_utils import get_month, get_fiscal_year +from odoo.tools.misc import format_date + +import re +from collections import defaultdict +import json + + +class ReSequenceWizard(models.TransientModel): + _name = 'account.resequence.wizard' + _description = 'Remake the sequence of Journal Entries.' + + sequence_number_reset = fields.Char(compute='_compute_sequence_number_reset') + first_date = fields.Date(help="Date (inclusive) from which the numbers are resequenced.") + end_date = fields.Date(help="Date (inclusive) to which the numbers are resequenced. If not set, all Journal Entries up to the end of the period are resequenced.") + first_name = fields.Char(compute="_compute_first_name", readonly=False, store=True, required=True, string="First New Sequence") + ordering = fields.Selection([('keep', 'Keep current order'), ('date', 'Reorder by accounting date')], required=True, default='keep') + move_ids = fields.Many2many('account.move') + new_values = fields.Text(compute='_compute_new_values') + preview_moves = fields.Text(compute='_compute_preview_moves') + + @api.model + def default_get(self, fields_list): + values = super(ReSequenceWizard, self).default_get(fields_list) + active_move_ids = self.env['account.move'] + if self.env.context.get('select_all') and self.env.context.get('active_model') == 'account.move' and 'active_domain' in self.env.context: + active_move_ids = self.env['account.move'].search(self.env.context['active_domain']) + elif self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context: + active_move_ids = self.env['account.move'].browse(self.env.context['active_ids']) + if len(active_move_ids.journal_id) > 1: + raise UserError(_('You can only resequence items from the same journal')) + if active_move_ids.journal_id.refund_sequence and len(set(active_move_ids.mapped('type')) - {'out_receipt', 'in_receipt'}) > 1: + raise UserError(_('The sequences of this journal are different for Invoices and Refunds but you selected some of both types.')) + values['move_ids'] = [(6, 0, active_move_ids.ids)] + return values + + @api.depends('first_name') + def _compute_sequence_number_reset(self): + for record in self: + record.sequence_number_reset = self.move_ids[0]._deduce_sequence_number_reset(record.first_name) + + @api.depends('move_ids') + def _compute_first_name(self): + self.first_name = "" + for record in self: + if record.move_ids: + record.first_name = min(record.move_ids._origin.mapped('name')) + + @api.depends('new_values', 'ordering') + def _compute_preview_moves(self): + """Reduce the computed new_values to a smaller set to display in the preview.""" + for record in self: + new_values = sorted(json.loads(record.new_values).values(), key=lambda x: x['server-date'], reverse=True) + changeLines = [] + in_elipsis = 0 + previous_line = None + 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 == 'month' and line['server-date'][0:7] != previous_line['server-date'][0:7]): + if in_elipsis: + changeLines.append({'current_name': '... (%s other)' % str(in_elipsis), 'new_by_name': '...', 'new_by_date': '...', 'date': '...'}) + in_elipsis = 0 + changeLines.append(line) + else: + in_elipsis += 1 + previous_line = line + + record.preview_moves = json.dumps({ + 'ordering': record.ordering, + 'changeLines': changeLines, + }) + + @api.depends('first_name', 'move_ids', 'sequence_number_reset') + def _compute_new_values(self): + """Compute the proposed new values. + + Sets a json string on new_values representing a dictionary thats maps account.move + ids to a disctionay containing the name if we execute the action, and information + relative to the preview widget. + """ + def _get_move_key(move_id): + if self.sequence_number_reset == 'year': + return move_id.date.year + elif self.sequence_number_reset == 'month': + return (move_id.date.year, move_id.date.month) + return 'default' + + def _sort_by_name_key(name): + match = re.match(self.move_ids[0]._sequence_fixed_regex, name) + return (match.group('prefix1'), int(match.group('seq') or '0'), match.group('suffix')) + + self.new_values = "{}" + for record in self.filtered('first_name'): + moves_by_period = defaultdict(lambda: record.env['account.move']) + for move in record.move_ids._origin: # Sort the moves by period depending on the sequence number reset + moves_by_period[_get_move_key(move)] += move + + if record.sequence_number_reset == 'month': + sequence = re.match(self.move_ids[0]._sequence_monthly_regex, record.first_name) + format = '{prefix1}%(year)04d{prefix2}%(month)02d{prefix3}%(seq)0{len}d{suffix}'.format( + prefix1=sequence.group('prefix1'), + prefix2=sequence.group('prefix2'), + prefix3=sequence.group('prefix3'), + len=len(sequence.group('seq')), + suffix=sequence.group('suffix'), + ) + elif record.sequence_number_reset == 'year': + sequence = re.match(self.move_ids[0]._sequence_yearly_regex, record.first_name) + format = '{prefix1}%(year)04d{prefix2}%(seq)0{len}d{suffix}'.format( + prefix1=sequence.group('prefix1'), + prefix2=sequence.group('prefix2'), + len=len(sequence.group('seq')), + suffix=sequence.group('suffix'), + ) + else: + sequence = re.match(self.move_ids[0]._sequence_fixed_regex, record.first_name) + format = '{prefix}%(seq)0{len}d{suffix}'.format( + prefix=sequence.group('prefix1'), + len=len(sequence.group('seq')), + suffix=sequence.group('suffix'), + ) + + new_values = {} + for j, period_recs in enumerate(moves_by_period.values()): + # compute the new values period by period + for move in period_recs: + new_values[move.id] = { + 'current_name': move.name, + 'state': move.state, + 'date': format_date(self.env, move.date), + 'server-date': str(move.date), + } + + new_name_list = [format % { + 'year': period_recs[0].date.year, + 'month': period_recs[0].date.month, + 'seq': i + (int(sequence.group('seq') or '1') if j == (len(moves_by_period)-1) else 1), + } for i in range(len(period_recs))] + + # For all the moves of this period, assign the name by increasing initial name + for move, new_name in zip(period_recs.sorted(lambda m: _sort_by_name_key(m.name)), new_name_list): + new_values[move.id]['new_by_name'] = new_name + # For all the moves of this period, assign the name by increasing date + for move, new_name in zip(period_recs.sorted(lambda m: (m.date, m.name, m.id)), new_name_list): + new_values[move.id]['new_by_date'] = new_name + + record.new_values = json.dumps(new_values) + + def resequence(self): + new_values = json.loads(self.new_values) + # Can't change the name of a posted invoice, but we do not want to have the chatter + # logging 3 separate changes with [state to draft], [change of name], [state to posted] + self.with_context(tracking_disable=True).move_ids.state = 'draft' + for move_id in self.move_ids: + if str(move_id.id) in new_values: + if self.ordering == 'keep': + move_id.name = new_values[str(move_id.id)]['new_by_name'] + else: + move_id.name = new_values[str(move_id.id)]['new_by_date'] + move_id.with_context(tracking_disable=True).state = new_values[str(move_id.id)]['state'] diff --git a/addons/account/wizard/account_resequence_views.xml b/addons/account/wizard/account_resequence_views.xml new file mode 100644 index 00000000000..853c0240ae8 --- /dev/null +++ b/addons/account/wizard/account_resequence_views.xml @@ -0,0 +1,41 @@ + + + + + Re-sequence Journal Entries + account.resequence.wizard + +
+ + + + + + + + + + + +
+ + + Resequence + account.resequence.wizard + form + + new + + + list + +
+
diff --git a/addons/account/wizard/account_validate_account_move.py b/addons/account/wizard/account_validate_account_move.py index 922f5685a18..a7e5bef2e24 100644 --- a/addons/account/wizard/account_validate_account_move.py +++ b/addons/account/wizard/account_validate_account_move.py @@ -9,7 +9,7 @@ class ValidateAccountMove(models.TransientModel): def validate_move(self): context = dict(self._context or {}) moves = self.env['account.move'].browse(context.get('active_ids')) - move_to_post = moves.filtered(lambda m: m.state == 'draft').sorted(lambda m: (m.date, m.ref or '', m.id)) + move_to_post = moves.filtered(lambda m: m.state == 'draft') if not move_to_post: raise UserError(_('There are no journal items in the draft state to post.')) move_to_post.post() diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py index 389e28a5d55..56b484ce92b 100644 --- a/addons/account/wizard/setup_wizards.py +++ b/addons/account/wizard/setup_wizards.py @@ -49,11 +49,12 @@ class FinancialYearOpeningWizard(models.TransientModel): wiz.company_id.write({ 'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day, 'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month, - 'account_opening_date': vals.get('opening_date'), + 'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date, }) wiz.company_id.account_opening_move_id.write({ - 'date': fields.Date.from_string(vals.get('opening_date')) - timedelta(days=1), + 'date': fields.Date.from_string(vals.get('opening_date') or wiz.company_id.account_opening_date) - timedelta(days=1), }) + vals.pop('opening_date', None) vals.pop('fiscalyear_last_day', None) vals.pop('fiscalyear_last_month', None) diff --git a/addons/l10n_ar/demo/account_customer_refund_demo.xml b/addons/l10n_ar/demo/account_customer_refund_demo.xml index 3c6528a72d2..d5c80ca849e 100644 --- a/addons/l10n_ar/demo/account_customer_refund_demo.xml +++ b/addons/l10n_ar/demo/account_customer_refund_demo.xml @@ -5,42 +5,27 @@ Mercadería defectuosa refund - - - - - Venta cancelada cancel - - - - - Venta cancelada cancel - - - - - diff --git a/addons/l10n_ar/demo/account_supplier_invoice_demo.xml b/addons/l10n_ar/demo/account_supplier_invoice_demo.xml index e9e0285e174..05f794bad85 100644 --- a/addons/l10n_ar/demo/account_supplier_invoice_demo.xml +++ b/addons/l10n_ar/demo/account_supplier_invoice_demo.xml @@ -1,10 +1,9 @@ - - + FA-C 0001-00000008 @@ -23,6 +22,7 @@ + FA-A 0002-00000123 @@ -38,6 +38,7 @@ + FA-A 0003-00000312 @@ -52,6 +53,7 @@ + FA-A 0001-00000200 @@ -66,6 +68,7 @@ + FA-A 0001-00000222 @@ -84,6 +87,7 @@ + FA-I 0001-00000333 @@ -97,6 +101,7 @@ + FA-I 0001-00000334 @@ -111,6 +116,7 @@ + DI 16052IC04000605L @@ -216,31 +222,6 @@ - - 0001-00000008 - - - 0002-00000123 - - - 0003-00000312 - - - 0001-00000200 - - - 0001-00000222 - - - 0001-00000333 - - - 0001-00000334 - - - 16052IC04000605L - - diff --git a/addons/l10n_ar/demo/account_supplier_refund_demo.xml b/addons/l10n_ar/demo/account_supplier_refund_demo.xml index 76aa97dd74b..3866523a65c 100644 --- a/addons/l10n_ar/demo/account_supplier_refund_demo.xml +++ b/addons/l10n_ar/demo/account_supplier_refund_demo.xml @@ -5,30 +5,18 @@ Mercadería defectuosa refund - 0001-01234567 - - - - - Venta cancelada - 0001-01234566 - cancel - - - - diff --git a/addons/l10n_ar/models/account_move.py b/addons/l10n_ar/models/account_move.py index 99652d0b71a..ac5c915cb0a 100644 --- a/addons/l10n_ar/models/account_move.py +++ b/addons/l10n_ar/models/account_move.py @@ -111,18 +111,6 @@ class AccountMove(models.Model): 'message': _('Please configure the AFIP Responsibility for "%s" in order to continue') % ( self.partner_id.name)}} - def _get_document_type_sequence(self): - """ Return the match sequences for the given journal and invoice """ - self.ensure_one() - if self.journal_id.l10n_latam_use_documents and self.l10n_latam_country_code == 'AR': - if self.journal_id.l10n_ar_share_sequences: - return self.journal_id.l10n_ar_sequence_ids.filtered( - lambda x: x.l10n_ar_letter == self.l10n_latam_document_type_id.l10n_ar_letter) - res = self.journal_id.l10n_ar_sequence_ids.filtered( - lambda x: x.l10n_latam_document_type_id == self.l10n_latam_document_type_id) - return res - return super()._get_document_type_sequence() - @api.onchange('partner_id') def _onchange_partner_journal(self): """ This method is used when the invoice is created from the sale or subscription """ diff --git a/addons/l10n_ar/views/account_journal_view.xml b/addons/l10n_ar/views/account_journal_view.xml index 3fab8f329d5..cec73b81d73 100644 --- a/addons/l10n_ar/views/account_journal_view.xml +++ b/addons/l10n_ar/views/account_journal_view.xml @@ -15,15 +15,6 @@
- - - {'invisible': [('l10n_latam_use_documents', '=', True), ('l10n_latam_country_code', '=', 'AR')]} - - - {'invisible': [('l10n_latam_use_documents', '=', True), ('l10n_latam_country_code', '=', 'AR')]} - diff --git a/addons/l10n_cl/models/account_move.py b/addons/l10n_cl/models/account_move.py index 5a261d28d39..215395c8036 100644 --- a/addons/l10n_cl/models/account_move.py +++ b/addons/l10n_cl/models/account_move.py @@ -13,15 +13,6 @@ class AccountMove(models.Model): l10n_latam_internal_type = fields.Selection( related='l10n_latam_document_type_id.internal_type', string='L10n Latam Internal Type') - def _get_document_type_sequence(self): - """ Return the match sequences for the given journal and invoice """ - self.ensure_one() - if self.journal_id.l10n_latam_use_documents and self.l10n_latam_country_code == 'CL': - res = self.journal_id.l10n_cl_sequence_ids.filtered( - lambda x: x.l10n_latam_document_type_id == self.l10n_latam_document_type_id) - return res - return super()._get_document_type_sequence() - def _get_l10n_latam_documents_domain(self): self.ensure_one() domain = super()._get_l10n_latam_documents_domain() diff --git a/addons/l10n_cl/views/account_journal_view.xml b/addons/l10n_cl/views/account_journal_view.xml index b679e45432e..7b79e814634 100644 --- a/addons/l10n_cl/views/account_journal_view.xml +++ b/addons/l10n_cl/views/account_journal_view.xml @@ -19,16 +19,7 @@ - - - {'invisible': [('l10n_latam_use_documents', '=', True), ('l10n_latam_country_code', '=', 'CL')]} - - - {'invisible': [('l10n_latam_use_documents', '=', True), ('l10n_latam_country_code', '=', 'CL')]} - - \ No newline at end of file + diff --git a/addons/l10n_cl/views/account_move_view.xml b/addons/l10n_cl/views/account_move_view.xml index 6266b080f17..ecf9c7f8589 100644 --- a/addons/l10n_cl/views/account_move_view.xml +++ b/addons/l10n_cl/views/account_move_view.xml @@ -21,7 +21,6 @@ - diff --git a/addons/l10n_generic_coa/demo/account_bank_statement_demo.xml b/addons/l10n_generic_coa/demo/account_bank_statement_demo.xml index e5c0f983c00..835987779c1 100644 --- a/addons/l10n_generic_coa/demo/account_bank_statement_demo.xml +++ b/addons/l10n_generic_coa/demo/account_bank_statement_demo.xml @@ -6,7 +6,7 @@ ('type', '=', 'bank'), ('company_id', '=', obj().env.company.id)]"/> - + 8998.2 5103.0 @@ -15,7 +15,7 @@ 1 - + @@ -53,7 +53,7 @@ 4 - + @@ -82,7 +82,7 @@ 1 - + diff --git a/addons/l10n_latam_invoice_document/models/account_move.py b/addons/l10n_latam_invoice_document/models/account_move.py index fb6d8b0541b..52b8f960a74 100644 --- a/addons/l10n_latam_invoice_document/models/account_move.py +++ b/addons/l10n_latam_invoice_document/models/account_move.py @@ -3,6 +3,7 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError from functools import partial +import re from odoo.tools.misc import formatLang @@ -16,48 +17,27 @@ class AccountMove(models.Model): l10n_latam_document_type_id = fields.Many2one( 'l10n_latam.document.type', string='Document Type', copy=False, readonly=False, auto_join=True, index=True, states={'posted': [('readonly', True)]}, compute='_compute_l10n_latam_document_type', store=True) - l10n_latam_sequence_id = fields.Many2one('ir.sequence', compute='_compute_l10n_latam_sequence') - l10n_latam_document_number = fields.Char( - compute='_compute_l10n_latam_document_number', inverse='_inverse_l10n_latam_document_number', - string='Document Number', readonly=True, states={'draft': [('readonly', False)]}) l10n_latam_use_documents = fields.Boolean(related='journal_id.l10n_latam_use_documents') l10n_latam_country_code = fields.Char( related='company_id.country_id.code', help='Technical field used to hide/show fields regarding the localization') - def _get_sequence_prefix(self): - """ If we use documents we update sequences only from journal """ - return super(AccountMove, self.filtered(lambda x: not x.l10n_latam_use_documents))._get_sequence_prefix() + @api.model + def _deduce_sequence_number_reset(self, name): + if self.l10n_latam_use_documents: + return 'never' + return super(AccountMove, self)._deduce_sequence_number_reset(name) - @api.depends('name') - def _compute_l10n_latam_document_number(self): - recs_with_name = self.filtered(lambda x: x.name != '/') - for rec in recs_with_name: - name = rec.name - doc_code_prefix = rec.l10n_latam_document_type_id.doc_code_prefix - if doc_code_prefix and name: - name = name.split(" ", 1)[-1] - rec.l10n_latam_document_number = name - remaining = self - recs_with_name - remaining.l10n_latam_document_number = False + def _get_last_sequence_domain(self, relaxed=False): + where_string, param = super(AccountMove, self)._get_last_sequence_domain(relaxed) + if self.l10n_latam_use_documents: + where_string += " AND l10n_latam_document_type_id = %(l10n_latam_document_type_id)s " + param['l10n_latam_document_type_id'] = self.l10n_latam_document_type_id.id or 0 + return where_string, param - @api.onchange('l10n_latam_document_type_id', 'l10n_latam_document_number') - def _inverse_l10n_latam_document_number(self): - for rec in self.filtered('l10n_latam_document_type_id'): - if not rec.l10n_latam_document_number: - rec.name = '/' - else: - l10n_latam_document_number = rec.l10n_latam_document_type_id._format_document_number(rec.l10n_latam_document_number) - if rec.l10n_latam_document_number != l10n_latam_document_number: - rec.l10n_latam_document_number = l10n_latam_document_number - rec.name = "%s %s" % (rec.l10n_latam_document_type_id.doc_code_prefix, l10n_latam_document_number) - - @api.depends('l10n_latam_document_type_id', 'journal_id') - def _compute_l10n_latam_sequence(self): - recs_with_journal_id = self.filtered('journal_id') - for rec in recs_with_journal_id: - rec.l10n_latam_sequence_id = rec._get_document_type_sequence() - remaining = self - recs_with_journal_id - remaining.l10n_latam_sequence_id = False + def _get_starting_sequence(self): + if self.l10n_latam_use_documents: + return "%s 0001-00000000" % (self.l10n_latam_document_type_id.doc_code_prefix) + return super(AccountMove, self)._get_starting_sequence() def _compute_l10n_latam_amount_and_taxes(self): recs_invoice = self.filtered(lambda x: x.is_invoice()) @@ -82,20 +62,10 @@ class AccountMove(models.Model): remaining.l10n_latam_amount_untaxed = False remaining.l10n_latam_tax_ids = [(5, 0)] - def _compute_invoice_sequence_number_next(self): - """ If journal use documents disable the next number header""" - with_latam_document_number = self.filtered('l10n_latam_use_documents') - with_latam_document_number.invoice_sequence_number_next_prefix = False - with_latam_document_number.invoice_sequence_number_next = False - return super(AccountMove, self - with_latam_document_number)._compute_invoice_sequence_number_next() - def post(self): for rec in self.filtered(lambda x: x.l10n_latam_use_documents and (not x.name or x.name == '/')): - if not rec.l10n_latam_sequence_id: - raise UserError(_('No sequence or document number linked to invoice id %s') % rec.id) if rec.type in ('in_receipt', 'out_receipt'): raise UserError(_('We do not accept the usage of document types on receipts yet. ')) - rec.l10n_latam_document_number = rec.l10n_latam_sequence_id.next_by_id() return super().post() @api.constrains('name', 'journal_id', 'state') @@ -122,10 +92,10 @@ class AccountMove(models.Model): raise ValidationError(_( 'The journal require a document type but not document type has been selected on invoices %s.' % ( without_doc_type.ids))) - without_number = validated_invoices.filtered( - lambda x: not x.l10n_latam_document_number and not x.l10n_latam_sequence_id) + valid = re.compile(r'[A-Z\-]+\s*\d{1,5}\-\d{1,8}') + without_number = validated_invoices.filtered(lambda x: not valid.match(x.name)) if without_number: - raise ValidationError(_('Please set the document number on the following invoices %s.' % ( + raise ValidationError(_('The document number on the following invoices is not correct %s.' % ( without_number.ids))) @api.constrains('type', 'l10n_latam_document_type_id') @@ -193,24 +163,20 @@ class AccountMove(models.Model): ) for group, amounts in res] super(AccountMove, self - move_with_doc_type)._compute_invoice_taxes_by_group() - def _get_document_type_sequence(self): - """ Method to be inherited by different localizations. """ - self.ensure_one() - return self.env['ir.sequence'] - @api.constrains('name', 'partner_id', 'company_id') def _check_unique_vendor_number(self): """ The constraint _check_unique_sequence_number is valid for customer bills but not valid for us on vendor bills because the uniqueness must be per partner and also because we want to validate on entry creation and not on entry validation """ - for rec in self.filtered(lambda x: x.is_purchase_document() and x.l10n_latam_use_documents and x.l10n_latam_document_number): + for rec in self.filtered(lambda x: x.is_purchase_document() and x.l10n_latam_use_documents): domain = [ ('type', '=', rec.type), - # by validating name we validate l10n_latam_document_number and l10n_latam_document_type_id + # by validating name we validate l10n_latam_document_type_id ('name', '=', rec.name), ('company_id', '=', rec.company_id.id), ('id', '!=', rec.id), - ('commercial_partner_id', '=', rec.commercial_partner_id.id) + ('commercial_partner_id', '=', rec.commercial_partner_id.id), + ('posted_before', '=', True), ] if rec.search(domain): raise ValidationError(_('Vendor bill number must be unique per vendor and company.')) diff --git a/addons/l10n_latam_invoice_document/views/account_move_view.xml b/addons/l10n_latam_invoice_document/views/account_move_view.xml index a8df0d65233..32dd56279da 100644 --- a/addons/l10n_latam_invoice_document/views/account_move_view.xml +++ b/addons/l10n_latam_invoice_document/views/account_move_view.xml @@ -38,15 +38,12 @@ - - diff --git a/addons/l10n_latam_invoice_document/views/report_invoice.xml b/addons/l10n_latam_invoice_document/views/report_invoice.xml index fc2784bb033..f1a775f8888 100644 --- a/addons/l10n_latam_invoice_document/views/report_invoice.xml +++ b/addons/l10n_latam_invoice_document/views/report_invoice.xml @@ -13,7 +13,6 @@

-

diff --git a/addons/l10n_latam_invoice_document/wizards/account_move_reversal.py b/addons/l10n_latam_invoice_document/wizards/account_move_reversal.py index de81b3c6ba7..0169859a098 100644 --- a/addons/l10n_latam_invoice_document/wizards/account_move_reversal.py +++ b/addons/l10n_latam_invoice_document/wizards/account_move_reversal.py @@ -5,27 +5,11 @@ from odoo.exceptions import UserError class AccountMoveReversal(models.TransientModel): - _inherit = "account.move.reversal" - l10n_latam_use_documents = fields.Boolean(readonly=True) - l10n_latam_document_type_id = fields.Many2one('l10n_latam.document.type', 'Document Type', ondelete='cascade', domain="[('id', 'in', l10n_latam_available_document_type_ids)]") - l10n_latam_available_document_type_ids = fields.Many2many('l10n_latam.document.type', store=False) - l10n_latam_sequence_id = fields.Many2one('ir.sequence', compute='_compute_l10n_latam_sequence') - l10n_latam_document_number = fields.Char(string='Document Number') - - @api.model - def default_get(self, fields): - res = super(AccountMoveReversal, self).default_get(fields) - move_ids = self.env['account.move'].browse(self.env.context['active_ids']) if self.env.context.get('active_model') == 'account.move' else self.env['account.move'] - if len(move_ids) > 1: - move_ids_use_document = move_ids.filtered(lambda move: move.l10n_latam_use_documents) - if move_ids_use_document: - raise UserError(_('You can only reverse documents with legal invoicing documents from Latin America one at a time.\nProblematic documents: %s') % ", ".join(move_ids_use_document.mapped('name'))) - else: - res['l10n_latam_use_documents'] = move_ids.journal_id.l10n_latam_use_documents - - return res + l10n_latam_use_documents = fields.Boolean(compute='_compute_document_type') + l10n_latam_document_type_id = fields.Many2one('l10n_latam.document.type', 'Document Type', ondelete='cascade', domain="[('id', 'in', l10n_latam_available_document_type_ids)]", compute='_compute_document_type', readonly=False) + l10n_latam_available_document_type_ids = fields.Many2many('l10n_latam.document.type', compute='_compute_document_type') @api.model def _reverse_type_map(self, move_type): @@ -38,41 +22,29 @@ class AccountMoveReversal(models.TransientModel): 'in_receipt': 'out_receipt'} return match.get(move_type) - @api.onchange('move_ids') - def _onchange_move_id(self): - if self.l10n_latam_use_documents: - refund = self.env['account.move'].new({ - 'type': self._reverse_type_map(self.move_ids.type), - 'journal_id': self.move_ids.journal_id.id, - 'partner_id': self.move_ids.partner_id.id, - 'company_id': self.move_ids.company_id.id, - }) - self.l10n_latam_document_type_id = refund.l10n_latam_document_type_id - self.l10n_latam_available_document_type_ids = refund.l10n_latam_available_document_type_ids + @api.depends('move_ids') + def _compute_document_type(self): + self.l10n_latam_available_document_type_ids = False + self.l10n_latam_document_type_id = False + self.l10n_latam_use_documents = False + for record in self: + if len(record.move_ids) > 1: + move_ids_use_document = record.move_ids._origin.filtered(lambda move: move.l10n_latam_use_documents) + if move_ids_use_document: + raise UserError(_('You can only reverse documents with legal invoicing documents from Latin America one at a time.\nProblematic documents: %s') % ", ".join(move_ids_use_document.mapped('name'))) + else: + record.l10n_latam_use_documents = record.move_ids.journal_id.l10n_latam_use_documents + + if record.l10n_latam_use_documents: + refund = record.env['account.move'].new({ + 'type': record._reverse_type_map(record.move_ids.type), + 'journal_id': record.move_ids.journal_id.id, + 'partner_id': record.move_ids.partner_id.id, + 'company_id': record.move_ids.company_id.id, + }) + record.l10n_latam_document_type_id = refund.l10n_latam_document_type_id + record.l10n_latam_available_document_type_ids = refund.l10n_latam_available_document_type_ids def reverse_moves(self): return super(AccountMoveReversal, self.with_context( - default_l10n_latam_document_type_id=self.l10n_latam_document_type_id.id, - default_l10n_latam_document_number=self.l10n_latam_document_number)).reverse_moves() - - @api.depends('l10n_latam_document_type_id') - def _compute_l10n_latam_sequence(self): - for rec in self: - rec.l10n_latam_sequence_id = False - if len(rec.move_ids) <= 1: - refund = rec.env['account.move'].new({ - 'type': self._reverse_type_map(rec.move_ids.type), - 'journal_id': rec.move_ids.journal_id.id, - 'partner_id': rec.move_ids.partner_id.id, - 'company_id': rec.move_ids.company_id.id, - 'l10n_latam_document_type_id': rec.l10n_latam_document_type_id.id, - }) - rec.l10n_latam_sequence_id = refund._get_document_type_sequence() - - @api.onchange('l10n_latam_document_number', 'l10n_latam_document_type_id') - def _onchange_l10n_latam_document_number(self): - if self.l10n_latam_document_type_id: - l10n_latam_document_number = self.l10n_latam_document_type_id._format_document_number( - self.l10n_latam_document_number) - if self.l10n_latam_document_number != l10n_latam_document_number: - self.l10n_latam_document_number = l10n_latam_document_number + default_l10n_latam_document_type_id=self.l10n_latam_document_type_id.id)).reverse_moves() diff --git a/addons/l10n_latam_invoice_document/wizards/account_move_reversal_view.xml b/addons/l10n_latam_invoice_document/wizards/account_move_reversal_view.xml index 9022fbd86ee..f2d8b96527a 100644 --- a/addons/l10n_latam_invoice_document/wizards/account_move_reversal_view.xml +++ b/addons/l10n_latam_invoice_document/wizards/account_move_reversal_view.xml @@ -8,12 +8,10 @@
- - diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py index a67158a3d11..b58e1ec6c20 100644 --- a/addons/sale/tests/test_sale_to_invoice.py +++ b/addons/sale/tests/test_sale_to_invoice.py @@ -99,7 +99,7 @@ class TestSaleToInvoice(TestCommonSaleNoChart): self.assertEqual(len(self.sale_order.invoice_ids), 2, 'Invoice should be created for the SO') - invoice = self.sale_order.invoice_ids.sorted()[0] + invoice = self.sale_order.invoice_ids.sorted()[-1] self.assertEqual(len(invoice.invoice_line_ids), len(self.sale_order.order_line), 'All lines should be invoiced') self.assertEqual(invoice.amount_total, self.sale_order.amount_total - downpayment_line.price_unit, 'Downpayment should be applied') diff --git a/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py b/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py index 87109bece04..dfc271f125e 100644 --- a/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py +++ b/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py @@ -47,7 +47,9 @@ class ValuationReconciliationTestCommon(StockAccountTestCommon): def _change_pickings_date(self, pickings, date): pickings.mapped('move_lines').write({'date': date}) + pickings.mapped('move_lines.account_move_ids').write({'name': '/', 'state': 'draft'}) pickings.mapped('move_lines.account_move_ids').write({'date': date}) + pickings.move_lines.account_move_ids.post() def _create_product_category(self): return self.env['product.category'].create({