diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index da47decc260..58f349a73dc 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -31,7 +31,6 @@ You could use this simplified accounting in case you work with an (external) acc 'wizard/account_payment_register_views.xml', 'views/account_move_views.xml', 'wizard/setup_wizards_view.xml', - 'wizard/pos_box.xml', 'views/account_account_views.xml', 'views/account_group_views.xml', 'views/account_journal_views.xml', diff --git a/addons/account/demo/account_demo.py b/addons/account/demo/account_demo.py index e8de5d220d8..566ede3a17e 100644 --- a/addons/account/demo/account_demo.py +++ b/addons/account/demo/account_demo.py @@ -5,7 +5,6 @@ from datetime import timedelta from dateutil.relativedelta import relativedelta from odoo import api, fields, models, Command -from odoo.exceptions import UserError, ValidationError from odoo.tools.misc import file_open, formatLang _logger = logging.getLogger(__name__) @@ -104,51 +103,71 @@ class AccountChartTemplate(models.Model): def _get_demo_data_statement(self): cid = self.env.company.id ref = self.env.ref + bnk_journal = self.env['account.journal'].search( + domain=[('type', '=', 'bank'), ('company_id', '=', cid)], + limit=1, + ) return ('account.bank.statement', { + f'{cid}_demo_bank_statement_0': { + 'name': f'{bnk_journal.name} - {time.strftime("%Y")}-01-01/1', + 'balance_end_real': 5103.0, + 'balance_start': 0.0, + 'line_ids': [ + Command.create({ + 'journal_id': bnk_journal.id, + 'payment_ref': 'Initial balance', + 'amount': 5103.0, + 'date': time.strftime('%Y-01-01'), + }), + ] + }, f'{cid}_demo_bank_statement_1': { - 'journal_id': self.env['account.journal'].search([ - ('type', '=', 'bank'), - ('company_id', '=', cid), - ], limit=1).id, - 'date': time.strftime('%Y')+'-01-01', + 'name': f'{bnk_journal.name} - {time.strftime("%Y")}-01-01/2', 'balance_end_real': 9944.87, 'balance_start': 5103.0, 'line_ids': [ Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': time.strftime('INV/%Y/00002 and INV/%Y/00003'), 'amount': 1275.0, 'date': time.strftime('%Y-01-01'), 'partner_id': ref('base.res_partner_12').id }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': 'Bank Fees', 'amount': -32.58, 'date': time.strftime('%Y-01-01'), }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': 'Prepayment', 'amount': 650, 'date': time.strftime('%Y-01-01'), 'partner_id': ref('base.res_partner_12').id }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': time.strftime(f'First {formatLang(self.env, 2000, currency_obj=self.env.company.currency_id)} of invoice %Y/00001'), 'amount': 2000, 'date': time.strftime('%Y-01-01'), 'partner_id': ref('base.res_partner_12').id }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': 'Last Year Interests', 'amount': 102.78, 'date': time.strftime('%Y-01-01'), }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': time.strftime('INV/%Y/00002'), 'amount': 750, 'date': time.strftime('%Y-01-01'), 'partner_id': ref('base.res_partner_2').id }), Command.create({ + 'journal_id': bnk_journal.id, 'payment_ref': f'R:9772938 10/07 AX 9415116318 T:5 BRT: {formatLang(self.env, 100.0, digits=2)} C/ croip', 'amount': 96.67, 'date': time.strftime('%Y-01-01'), @@ -315,8 +334,6 @@ class AccountChartTemplate(models.Model): move.action_post() except Exception: _logger.exception('Error while posting demo data') - elif created._name == 'account.bank.statement': - created.button_post() @api.model def _get_demo_account(self, xml_id, account_type, company): diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index 4bd291d3e70..8b2f00c0176 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -17,6 +17,7 @@ from . import account_full_reconcile from . import account_payment from . import account_payment_method from . import account_bank_statement +from . import account_bank_statement_line from . import chart_template from . import account_analytic_account from . import account_analytic_distribution_model diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index f48ecafe397..b96f50fe617 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -1,1119 +1,242 @@ # -*- coding: utf-8 -*- - -from odoo import api, fields, models, _ -from odoo.tools import float_is_zero, html2plaintext -from odoo.tools.misc import formatLang, format_date -from odoo.exceptions import UserError, ValidationError -from odoo.osv.expression import get_unaccent_wrapper -from odoo.addons.base.models.res_bank import sanitize_account_number - - -class AccountCashboxLine(models.Model): - """ Cash Box Details """ - _name = 'account.cashbox.line' - _description = 'CashBox Line' - _rec_name = 'coin_value' - _order = 'coin_value' - - @api.depends('coin_value', 'number') - def _sub_total(self): - """ Calculates Sub total""" - for cashbox_line in self: - cashbox_line.subtotal = cashbox_line.coin_value * cashbox_line.number - - coin_value = fields.Float(string='Coin/Bill Value', required=True, digits=0) - number = fields.Integer(string='Number of Coins/Bills') - subtotal = fields.Float(compute='_sub_total', string='Subtotal', digits=0, readonly=True) - cashbox_id = fields.Many2one('account.bank.statement.cashbox', string="Cashbox") - currency_id = fields.Many2one('res.currency', related='cashbox_id.currency_id') - - -class AccountBankStmtCashWizard(models.Model): - """ - Account Bank Statement popup that allows entering cash details. - """ - _name = 'account.bank.statement.cashbox' - _description = 'Bank Statement Cashbox' - _rec_name = 'id' - - cashbox_lines_ids = fields.One2many('account.cashbox.line', 'cashbox_id', string='Cashbox Lines') - start_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_start_id') - end_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_end_id') - total = fields.Float(compute='_compute_total') - currency_id = fields.Many2one('res.currency', compute='_compute_currency') - - @api.depends('start_bank_stmt_ids', 'end_bank_stmt_ids') - def _compute_currency(self): - for cashbox in self: - cashbox.currency_id = False - if cashbox.end_bank_stmt_ids: - cashbox.currency_id = cashbox.end_bank_stmt_ids[0].currency_id - if cashbox.start_bank_stmt_ids: - cashbox.currency_id = cashbox.start_bank_stmt_ids[0].currency_id - - @api.depends('cashbox_lines_ids', 'cashbox_lines_ids.coin_value', 'cashbox_lines_ids.number') - def _compute_total(self): - for cashbox in self: - cashbox.total = sum([line.subtotal for line in cashbox.cashbox_lines_ids]) - - @api.model - def default_get(self, fields): - vals = super(AccountBankStmtCashWizard, self).default_get(fields) - balance = self.env.context.get('balance') - statement_id = self.env.context.get('statement_id') - if 'start_bank_stmt_ids' in fields and not vals.get('start_bank_stmt_ids') and statement_id and balance == 'start': - vals['start_bank_stmt_ids'] = [(6, 0, [statement_id])] - if 'end_bank_stmt_ids' in fields and not vals.get('end_bank_stmt_ids') and statement_id and balance == 'close': - vals['end_bank_stmt_ids'] = [(6, 0, [statement_id])] - - return vals - - def name_get(self): - result = [] - for cashbox in self: - result.append((cashbox.id, str(cashbox.total))) - return result - - @api.model_create_multi - def create(self, vals): - cashboxes = super(AccountBankStmtCashWizard, self).create(vals) - cashboxes._validate_cashbox() - return cashboxes - - def write(self, vals): - res = super(AccountBankStmtCashWizard, self).write(vals) - self._validate_cashbox() - return res - - def _validate_cashbox(self): - for cashbox in self: - if cashbox.start_bank_stmt_ids: - cashbox.start_bank_stmt_ids.write({'balance_start': cashbox.total}) - if cashbox.end_bank_stmt_ids: - cashbox.end_bank_stmt_ids.write({'balance_end_real': cashbox.total}) - - -class AccountBankStmtCloseCheck(models.TransientModel): - """ - Account Bank Statement wizard that check that closing balance is correct. - """ - _name = 'account.bank.statement.closebalance' - _description = 'Bank Statement Closing Balance' - - def validate(self): - bnk_stmt_id = self.env.context.get('active_id', False) - if bnk_stmt_id: - self.env['account.bank.statement'].browse(bnk_stmt_id).button_validate() - return {'type': 'ir.actions.act_window_close'} +from odoo import api, fields, models, _, Command +from odoo.exceptions import UserError class AccountBankStatement(models.Model): _name = "account.bank.statement" _description = "Bank Statement" - _order = "date desc, name desc, id desc" - _inherit = ['mail.thread', 'sequence.mixin'] + _order = "first_line_index desc" _check_company_auto = True - _sequence_index = "journal_id" - - # Note: the reason why we did 2 separate function with the same dependencies (one for balance_start and one for balance_end_real) - # is because if we create a bank statement with a default value for one of the field but not the other, the compute method - # won't be called and therefore the other field will have a value of 0 and we don't want that. - @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real') - def _compute_starting_balance(self): - # When a bank statement is inserted out-of-order several fields needs to be recomputed. - # As the records to recompute are ordered by id, it may occur that the first record - # to recompute start a recursive recomputation of field balance_end_real - # To avoid this we sort the records by date - for statement in self.sorted(key=lambda s: s.date): - if statement.previous_statement_id.balance_end_real != statement.balance_start: - statement.balance_start = statement.previous_statement_id.balance_end_real - else: - # Need default value - statement.balance_start = statement.balance_start or 0.0 - - @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real') - def _compute_ending_balance(self): - latest_statement = self.env['account.bank.statement'].search([('journal_id', '=', self[0].journal_id.id)], limit=1) - for statement in self: - # recompute balance_end_real in case we are in a bank journal and if we change the - # balance_end_real of previous statement as we don't want - # holes in case we add a statement in between 2 others statements. - # We only do this for the bank journal as we use the balance_end_real in cash - # journal for verification and creating cash difference entries so we don't want - # to recompute the value in that case - if statement.journal_type == 'bank': - # If we are on last statement and that statement already has a balance_end_real, don't change the balance_end_real - # Otherwise, recompute balance_end_real to prevent holes between statement. - if latest_statement.id and statement.id == latest_statement.id and not float_is_zero(statement.balance_end_real, precision_digits=statement.currency_id.decimal_places): - statement.balance_end_real = statement.balance_end_real or 0.0 - else: - total_entry_encoding = sum([line.amount for line in statement.line_ids]) - statement.balance_end_real = statement.previous_statement_id.balance_end_real + total_entry_encoding - else: - # Need default value - statement.balance_end_real = statement.balance_end_real or 0.0 - - @api.depends('line_ids', 'balance_start', 'line_ids.amount', 'balance_end_real') - def _end_balance(self): - for statement in self: - statement.total_entry_encoding = sum([line.amount for line in statement.line_ids]) - statement.balance_end = statement.balance_start + statement.total_entry_encoding - statement.difference = statement.balance_end_real - statement.balance_end - - def _is_difference_zero(self): - for bank_stmt in self: - bank_stmt.is_difference_zero = float_is_zero(bank_stmt.difference, precision_digits=bank_stmt.currency_id.decimal_places) - - @api.depends('journal_id') - def _compute_currency(self): - for statement in self: - statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id - - @api.depends('move_line_ids') - def _get_move_line_count(self): - for statement in self: - statement.move_line_count = len(statement.move_line_ids) @api.model - def _default_journal(self): - journal_type = self.env.context.get('journal_type', False) - company_id = self.env.company.id - if journal_type: - return self.env['account.journal'].search([ - ('type', '=', journal_type), - ('company_id', '=', company_id) - ], limit=1) - return self.env['account.journal'] - - @api.depends('balance_start', 'previous_statement_id') - def _compute_is_valid_balance_start(self): - for bnk in self: - bnk.is_valid_balance_start = ( - bnk.currency_id.is_zero( - bnk.balance_start - bnk.previous_statement_id.balance_end_real - ) - if bnk.previous_statement_id - else True + def default_get(self, fields_list): + # EXTENDS base + defaults = super().default_get(fields_list) + # create statement on a saved statement line in the tree view + if self._context.get('st_line_id'): + st_line = self.env['account.bank.statement.line'].browse(self._context['st_line_id']) + defaults['balance_start'] = st_line.running_balance - st_line.amount + return defaults + # create statement from a new line in the tree view, not stored in the db yet + if self._context.get('st_line_date'): + defaults['balance_start'] = self.env['account.bank.statement.line'].search( + domain=[ + ('date', '<=', self._context['st_line_date']), + ('journal_id', '=', self._context.get('st_line_journal_id')), + ], + order='internal_index desc', + limit=1 + ).running_balance + return defaults + lines = None + # creating statements with split button + if self._context.get('split_line_id'): + current_st_line = self.env['account.bank.statement.line'].browse(self.env.context.get('split_line_id')) + line_before = self.env['account.bank.statement.line'].search( + domain=[ + ('internal_index', '<', current_st_line.internal_index), + ('journal_id', '=', current_st_line.journal_id.id), + ('statement_id', '!=', current_st_line.statement_id.id), + ('statement_id', '!=', False), + ], + order='internal_index desc', + limit=1, ) + lines = self.env['account.bank.statement.line'].search( + domain=[ + ('internal_index', '<=', current_st_line.internal_index), + ('internal_index', '>', line_before.internal_index or ''), + ('journal_id', '=', current_st_line.journal_id.id), + ], + order='internal_index desc', + ) + # if it is called from the action menu, we can have both start and end balances and we filter out + # completed statements, because it is probably due to a mistake from the user + elif self._context.get('active_model') == 'account.bank.statement.line' and self._context.get('active_ids'): + lines = self.env['account.bank.statement.line'].browse(self._context.get('active_ids')) \ + .filtered(lambda line: not line.statement_complete) \ + .sorted() + if not lines: + raise UserError(_('No editable line selected.')) + if lines: + defaults['line_ids'] = [Command.set(lines.ids)] + defaults['balance_start'] = lines[-1:].running_balance - lines[-1:].amount + defaults['balance_end_real'] = lines[:1].running_balance - @api.depends('date', 'journal_id') - def _get_previous_statement(self): - for st in self: - # Search for the previous statement - domain = [('date', '<=', st.date), ('journal_id', '=', st.journal_id.id)] - # The reason why we have to perform this test is because we have two use case here: - # First one is in case we are creating a new record, in that case that new record does - # not have any id yet. However if we are updating an existing record, the domain date <= st.date - # will find the record itself, so we have to add a condition in the search to ignore self.id - if not isinstance(st.id, models.NewId): - domain.extend(['|', '&', ('id', '<', st.id), ('date', '=', st.date), '&', ('id', '!=', st.id), ('date', '!=', st.date)]) - previous_statement = self.search(domain, limit=1, order='date desc, id desc') - st.previous_statement_id = previous_statement.id + return defaults - 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...)") - date = fields.Date(required=True, states={'confirm': [('readonly', True)]}, index=True, copy=False, default=fields.Date.context_today) - date_done = fields.Datetime(string="Closed On") - balance_start = fields.Monetary(string='Starting Balance', states={'confirm': [('readonly', True)]}, compute='_compute_starting_balance', readonly=False, store=True, tracking=True) - balance_end_real = fields.Monetary('Ending Balance', states={'confirm': [('readonly', True)]}, compute='_compute_ending_balance', recursive=True, readonly=False, store=True, tracking=True) - state = fields.Selection(string='Status', required=True, readonly=True, copy=False, tracking=True, selection=[ - ('open', 'New'), - ('posted', 'Processing'), - ('confirm', 'Validated'), - ], default='open', - help="The current state of your bank statement:" - "- New: Fully editable with draft Journal Entries." - "- Processing: No longer editable with posted Journal entries, ready for the reconciliation." - "- Validated: All lines are reconciled. There is nothing left to process.") - currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency") - journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'confirm': [('readonly', True)]}, default=_default_journal, check_company=True) - journal_type = fields.Selection(related='journal_id.type') # used for usability purposes - company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', store=True, readonly=True) - - total_entry_encoding = fields.Monetary('Transactions Subtotal', compute='_end_balance', store=True, help="Total of transaction lines.") - balance_end = fields.Monetary('Computed Balance', compute='_end_balance', store=True, help='Balance as calculated based on Opening Balance and transaction lines') - difference = fields.Monetary(compute='_end_balance', store=True, help="Difference between the computed ending balance and the specified ending balance.") - - line_ids = fields.One2many('account.bank.statement.line', 'statement_id', string='Statement lines', states={'confirm': [('readonly', True)]}, copy=True) - move_line_ids = fields.One2many('account.move.line', 'statement_id', string='Entry lines', states={'confirm': [('readonly', True)]}) - move_line_count = fields.Integer(compute="_get_move_line_count") - - all_lines_reconciled = fields.Boolean(compute='_compute_all_lines_reconciled') # are all statement lines are fully reconciled? - user_id = fields.Many2one('res.users', string='Responsible', required=False, default=lambda self: self.env.user) - cashbox_start_id = fields.Many2one('account.bank.statement.cashbox', string="Starting Cashbox") - cashbox_end_id = fields.Many2one('account.bank.statement.cashbox', string="Ending Cashbox") - is_difference_zero = fields.Boolean(compute='_is_difference_zero', string='Is zero', help="Check if difference is zero.") - previous_statement_id = fields.Many2one('account.bank.statement', compute='_get_previous_statement', store=True) - is_valid_balance_start = fields.Boolean(string="Is Valid Balance Start", store=True, - compute="_compute_is_valid_balance_start") # used to display a warning message if starting balance is different than previous ending balance - country_code = fields.Char(related='company_id.account_fiscal_country_id.code') - - def write(self, values): - res = super(AccountBankStatement, self).write(values) - if values.get('date') or values.get('journal'): - # If we are changing the date or journal of a bank statement, we have to change its previous_statement_id. This is done - # automatically using the compute function, but we also have to change the previous_statement_id of records that were - # previously pointing toward us and records that were pointing towards our new previous_statement_id. This is done here - # by marking those record as needing to be recomputed. - # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id' - # hence the need to call modified afterwards. - to_recompute = self.search([('previous_statement_id', 'in', self.ids), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)]) - if to_recompute: - self.env.add_to_compute(self._fields['previous_statement_id'], to_recompute) - to_recompute.modified(['previous_statement_id']) - next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in self]), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)]) - if next_statements_to_recompute: - self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute) - next_statements_to_recompute.modified(['previous_statement_id']) - return res - - @api.model_create_multi - def create(self, values): - res = super(AccountBankStatement, self).create(values) - # Upon bank stmt creation, it is possible that the statement is inserted between two other statements and not at the end - # In that case, we have to search for statement that are pointing to the same previous_statement_id as ourselve in order to - # change their previous_statement_id to us. This is done by marking the field 'previous_statement_id' to be recomputed for such records. - # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id' - # hence the need to call modified afterwards. - # The reason we are doing this here and not in a compute field is that it is not easy to write dependencies for such field. - next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in res]), ('id', 'not in', res.ids), ('journal_id', 'in', res.journal_id.ids)]) - if next_statements_to_recompute: - self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute) - next_statements_to_recompute.modified(['previous_statement_id']) - return res - - @api.depends('line_ids.is_reconciled') - def _compute_all_lines_reconciled(self): - for statement in self: - statement.all_lines_reconciled = all(st_line.is_reconciled for st_line in statement.line_ids) - - @api.onchange('journal_id') - def _onchange_journal_id(self): - for st_line in self.line_ids: - st_line.journal_id = self.journal_id - st_line.currency_id = self.journal_id.currency_id or self.company_id.currency_id - - def _check_balance_end_real_same_as_computed(self): - """ Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo). """ - return self._check_cash_balance_end_real_same_as_computed() and self._check_bank_balance_end_real_same_as_computed() - - def _check_cash_balance_end_real_same_as_computed(self): - """ Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo). - For a cash statement, if there is a difference, the different is set automatically to a profit/loss account. - """ - for statement in self.filtered(lambda stmt: stmt.journal_type == 'cash'): - if not statement.currency_id.is_zero(statement.difference): - st_line_vals = { - 'statement_id': statement.id, - 'journal_id': statement.journal_id.id, - 'amount': statement.difference, - 'date': statement.date, - } - - if statement.currency_id.compare_amounts(statement.difference, 0.0) < 0.0: - if not statement.journal_id.loss_account_id: - raise UserError(_( - "Please go on the %s journal and define a Loss Account. " - "This account will be used to record cash difference.", - statement.journal_id.name - )) - - st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Loss)") - st_line_vals['counterpart_account_id'] = statement.journal_id.loss_account_id.id - else: - # statement.difference > 0.0 - if not statement.journal_id.profit_account_id: - raise UserError(_( - "Please go on the %s journal and define a Profit Account. " - "This account will be used to record cash difference.", - statement.journal_id.name - )) - - st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Profit)") - st_line_vals['counterpart_account_id'] = statement.journal_id.profit_account_id.id - - self.env['account.bank.statement.line'].create(st_line_vals) - return True - - def _check_bank_balance_end_real_same_as_computed(self): - """ Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo). """ - for statement in self.filtered(lambda stmt: stmt.journal_type == 'bank'): - if not statement.currency_id.is_zero(statement.difference): - balance_end_real = formatLang(self.env, statement.balance_end_real, currency_obj=statement.currency_id) - balance_end = formatLang(self.env, statement.balance_end, currency_obj=statement.currency_id) - raise UserError(_( - 'The ending balance is incorrect !\nThe expected balance (%(real_balance)s) is different from the computed one (%(computed_balance)s).', - real_balance=balance_end_real, - computed_balance=balance_end - )) - return True - - @api.ondelete(at_uninstall=False) - def _unlink_only_if_open(self): - for statement in self: - if statement.state != 'open': - raise UserError(_('In order to delete a bank statement, you must first cancel it to delete related journal items.')) - - def unlink(self): - for statement in self: - # Explicitly unlink bank statement lines so it will check that the related journal entries have been deleted first - statement.line_ids.unlink() - # Some other bank statements might be link to this one, so in that case we have to switch the previous_statement_id - # from that statement to the one linked to this statement - next_statement = self.search([('previous_statement_id', '=', statement.id), ('journal_id', '=', statement.journal_id.id)]) - if next_statement: - next_statement.previous_statement_id = statement.previous_statement_id - return super(AccountBankStatement, self).unlink() - - # ------------------------------------------------------------------------- - # CONSTRAINT METHODS - # ------------------------------------------------------------------------- - - @api.constrains('journal_id') - def _check_journal(self): - for statement in self: - if any(st_line.journal_id != statement.journal_id for st_line in statement.line_ids): - raise ValidationError(_('The journal of a bank statement line must always be the same as the bank statement one.')) - - def _constrains_date_sequence(self): - # Multiple import methods set the name to things that are not sequences: - # i.e. Statement from {date1} to {date2} - # It makes this constraint not applicable, and it is less needed on bank statements as it - # is only an indication and not some thing legal. - return - - # ------------------------------------------------------------------------- - # BUSINESS METHODS - # ------------------------------------------------------------------------- - - def open_cashbox_id(self): - self.ensure_one() - context = dict(self.env.context or {}) - if context.get('balance'): - context['statement_id'] = self.id - if context['balance'] == 'start': - cashbox_id = self.cashbox_start_id.id - elif context['balance'] == 'close': - cashbox_id = self.cashbox_end_id.id - else: - cashbox_id = False - - action = { - 'name': _('Cash Control'), - 'view_mode': 'form', - 'res_model': 'account.bank.statement.cashbox', - 'view_id': self.env.ref('account.view_account_bnk_stmt_cashbox_footer').id, - 'type': 'ir.actions.act_window', - 'res_id': cashbox_id, - 'context': context, - 'target': 'new' - } - - return action - - def button_post(self): - ''' Move the bank statements from 'draft' to 'posted'. ''' - if any(statement.state != 'open' for statement in self): - raise UserError(_("Only new statements can be posted.")) - - self._check_cash_balance_end_real_same_as_computed() - - for statement in self: - if not statement.name: - statement._set_next_sequence() - - self.write({'state': 'posted'}) - lines_of_moves_to_post = self.line_ids.filtered(lambda line: line.move_id.state != 'posted') - if lines_of_moves_to_post: - lines_of_moves_to_post.move_id._post(soft=False) - - def button_validate(self): - if any(statement.state != 'posted' or not statement.all_lines_reconciled for statement in self): - raise UserError(_('All the account entries lines must be processed in order to validate the statement.')) - - for statement in self: - - # Chatter. - statement.message_post(body=_('Statement %s confirmed.', statement.name)) - - # Bank statement report. - if statement.journal_id.type == 'bank': - content = self.env["ir.actions.report"]._render_qweb_pdf('account.action_report_account_statement', statement.id)[0] - self.env['ir.attachment'].create({ - 'name': statement.name and _("Bank Statement %s.pdf", statement.name) or _("Bank Statement.pdf"), - 'type': 'binary', - 'raw': content, - 'res_model': statement._name, - 'res_id': statement.id - }) - - self._check_balance_end_real_same_as_computed() - self.write({'state': 'confirm', 'date_done': fields.Datetime.now()}) - - def button_validate_or_action(self): - if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference): - return self.env['ir.actions.act_window']._for_xml_id('account.action_view_account_bnk_stmt_check') - - return self.button_validate() - - def button_reopen(self): - ''' Move the bank statements back to the 'open' state. ''' - if any(statement.state == 'draft' for statement in self): - raise UserError(_("Only validated statements can be reset to new.")) - - self.write({'state': 'open'}) - self.line_ids.move_id.button_draft() - self.line_ids.button_undo_reconciliation() - - def button_reprocess(self): - """Move the bank statements back to the 'posted' state.""" - if any(statement.state != 'confirm' for statement in self): - raise UserError(_("Only Validated statements can be reset to new.")) - - self.write({'state': 'posted', 'date_done': False}) - - def button_journal_entries(self): - return { - 'name': _('Journal Entries'), - 'view_mode': 'tree', - 'res_model': 'account.move.line', - 'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id, - 'type': 'ir.actions.act_window', - 'domain': [('move_id', 'in', self.line_ids.move_id.ids)], - 'context': { - 'journal_id': self.journal_id.id, - 'group_by': 'move_id', - 'expand': True - } - } - - 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} - - if not relaxed: - domain = [('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id), ('name', '!=', False)] - previous_name = self.search(domain + [('date', '<', self.date)], order='date desc', limit=1).name - if not previous_name: - previous_name = self.search(domain, order='date desc', limit=1).name - sequence_number_reset = self._deduce_sequence_number_reset(previous_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 - return where_string, param - - def _get_starting_sequence(self): - self.ensure_one() - return "%s %s %04d/%02d/00000" % (self.journal_id.code, _('Statement'), self.date.year, self.date.month) - - -class AccountBankStatementLine(models.Model): - _name = "account.bank.statement.line" - _inherits = {'account.move': 'move_id'} - _description = "Bank Statement Line" - _order = "statement_id desc, date, sequence, id desc" - _check_company_auto = True - - # FIXME: Fields having the same name in both tables are confusing (partner_id & state). We don't change it because: - # - It's a mess to track/fix. - # - Some fields here could be simplified when the onchanges will be gone in account.move. - # Should be improved in the future. - - # == Business fields == - move_id = fields.Many2one( - comodel_name='account.move', - auto_join=True, - string='Journal Entry', required=True, readonly=True, ondelete='cascade', - check_company=True) - statement_id = fields.Many2one( - comodel_name='account.bank.statement', - string='Statement', index=True, required=True, ondelete='cascade', - check_company=True) - - sequence = fields.Integer(help="Gives the sequence order when displaying a list of bank statement lines.", default=1) - account_number = fields.Char(string='Bank Account Number', help="Technical field used to store the bank account number before its creation, upon the line's processing") - partner_name = fields.Char( - help="This field is used to record the third party name when importing bank statement in electronic format, " - "when the partner doesn't exist yet in the database (or cannot be found).") - transaction_type = fields.Char(string='Transaction Type') - payment_ref = fields.Char(string='Label', required=True) - amount = fields.Monetary(currency_field='currency_id') - amount_currency = fields.Monetary( - string="Amount in Currency", - currency_field='foreign_currency_id', - help="The amount expressed in an optional other currency if it is a multi-currency entry.", + name = fields.Char( + string='Reference', + copy=False, ) - foreign_currency_id = fields.Many2one( + + # Used to hold the reference of the external mean that created this statement (name of imported file, + # reference of online synchronization...) + reference = fields.Char( + string='External Reference', + copy=False, + ) + + date = fields.Date( + compute='_compute_date_index', store=True, + ) + + # The internal index of the first line of a statement, it is used for sorting the statements + # The date field cannot be used as there might be more than one statement in one day. + # keeping this order is important because the validity of the statements are based on their order + first_line_index = fields.Char( + comodel_name='account.bank.statement.line', + compute='_compute_date_index', store=True, index=True, + ) + + balance_start = fields.Monetary( + string='Starting Balance', + default=0.0, + ) + + # Balance end is calculated based on the statement line amounts and real starting balance. + balance_end = fields.Monetary( + string='Computed Balance', + compute='_compute_balance_end', store=True, + ) + + balance_end_real = fields.Monetary( + string='Ending Balance', + default=0.0, + ) + + company_id = fields.Many2one( + comodel_name='res.company', + related='journal_id.company_id', store=True, + ) + + currency_id = fields.Many2one( comodel_name='res.currency', - string="Foreign Currency", - help="The optional other currency if it is a multi-currency entry.", + compute='_compute_currency_id', ) - amount_residual = fields.Float(string="Residual Amount", - compute="_compute_is_reconciled", - store=True, - help="The amount left to be reconciled on this statement line (signed according to its move lines' balance), expressed in its currency. This is a technical field use to speedup the application of reconciliation models.") - currency_id = fields.Many2one('res.currency', string='Journal Currency') - partner_id = fields.Many2one( - comodel_name='res.partner', - string='Partner', ondelete='restrict', - domain="['|', ('parent_id','=', False), ('is_company','=',True)]", - check_company=True) - payment_ids = fields.Many2many( - comodel_name='account.payment', - relation='account_payment_account_bank_statement_line_rel', - string='Auto-generated Payments', - help="Payments generated during the reconciliation of this bank statement lines.") - # == Display purpose fields == - is_reconciled = fields.Boolean(string='Is Reconciled', store=True, - compute='_compute_is_reconciled', - help="Technical field indicating if the statement line is already reconciled.") - state = fields.Selection(related='statement_id.state', string='Status', readonly=True) - country_code = fields.Char(related='company_id.account_fiscal_country_id.code') + journal_id = fields.Many2one( + comodel_name='account.journal', + compute='_compute_journal_id', store=True, + check_company=True, + ) - # ------------------------------------------------------------------------- - # HELPERS - # ------------------------------------------------------------------------- + line_ids = fields.One2many( + comodel_name='account.bank.statement.line', + inverse_name='statement_id', + string='Statement lines', + required=True, + ) - def _seek_for_lines(self): - ''' Helper used to dispatch the journal items between: - - The lines using the liquidity account. - - The lines using the transfer account. - - The lines being not in one of the two previous categories. - :return: (liquidity_lines, suspense_lines, other_lines) - ''' - liquidity_lines = self.env['account.move.line'] - suspense_lines = self.env['account.move.line'] - other_lines = self.env['account.move.line'] + # A statement assumed to be complete when the sum of encoded lines is equal to the difference between initial and + # ending balances. In other words, a statement is complete when there are enough lines to fill the value between + # initial and final balances. When the user reaches this point, the statement is not autofilled on the new lines. + is_complete = fields.Boolean( + compute='_compute_is_complete', store=True, + ) - for line in self.move_id.line_ids: - if line.account_id == self.journal_id.default_account_id: - liquidity_lines += line - elif line.account_id == self.journal_id.suspense_account_id: - suspense_lines += line - else: - other_lines += line - return liquidity_lines, suspense_lines, other_lines + # A statement is considered valid when the starting balance matches the ending balance of the previous statement. + # The lines without statements are neglected because, either the user is using statements regularly, so they can + # assume every line without statement is problematic, or they don't use them regularly, in that case statements are + # working as checkpoints only and their validity is not important. + # The first statement of a journal is always considered valid. The validity of the statement is based on other + # statements, so one can say this is external integrity check were as is_complete is the internal integrity. + is_valid = fields.Boolean( + compute='_compute_is_valid', + search='_search_is_valid', + ) - def _prepare_counterpart_amounts_using_st_line_rate(self, currency, balance, amount_currency): - """ Convert the amounts passed as parameters to the statement line currency using the rates provided by the - bank. The computed amounts are the one that could be set on the statement line as a counterpart journal item - to fully paid the provided amounts as parameters. - - :param currency: The currency in which is expressed 'amount_currency'. - :param balance: The amount expressed in company currency. Only needed when the currency passed as - parameter is neither the statement line's foreign currency, neither the journal's - currency. - :param amount_currency: The amount expressed in the 'currency' passed as parameter. - :return: A python dictionary containing: - * balance: The amount to consider expressed in company's currency. - * amount_currency: The amount to consider expressed in statement line's foreign currency. - """ - self.ensure_one() - - journal = self.journal_id - company_currency = journal.company_id.currency_id - journal_currency = journal.currency_id or company_currency - foreign_currency = self.foreign_currency_id or journal_currency or company_currency - - journal_amount = self.amount - if foreign_currency == journal_currency: - transaction_amount = journal_amount - else: - transaction_amount = self.amount_currency - if journal_currency == company_currency: - company_amount = journal_amount - elif foreign_currency == company_currency: - company_amount = transaction_amount - else: - company_amount = journal_currency._convert(journal_amount, company_currency, journal.company_id, self.date) - - rate_journal2foreign_curr = journal_amount and abs(transaction_amount) / abs(journal_amount) - rate_comp2journal_curr = company_amount and abs(journal_amount) / abs(company_amount) - - if currency == foreign_currency: - trans_amount_currency = amount_currency - if rate_journal2foreign_curr: - journ_amount_currency = journal_currency.round(trans_amount_currency / rate_journal2foreign_curr) - else: - journ_amount_currency = 0.0 - if rate_comp2journal_curr: - new_balance = company_currency.round(journ_amount_currency / rate_comp2journal_curr) - else: - new_balance = 0.0 - elif currency == journal_currency: - trans_amount_currency = foreign_currency.round(amount_currency * rate_journal2foreign_curr) - if rate_comp2journal_curr: - new_balance = company_currency.round(amount_currency / rate_comp2journal_curr) - else: - new_balance = 0.0 - else: - journ_amount_currency = journal_currency.round(balance * rate_comp2journal_curr) - trans_amount_currency = foreign_currency.round(journ_amount_currency * rate_journal2foreign_curr) - new_balance = balance - - return { - 'amount_currency': trans_amount_currency, - 'balance': new_balance, - } - - def _prepare_move_line_default_vals(self, counterpart_account_id=None): - ''' Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line - record. - :return: A list of python dictionary to be passed to the account.move.line's 'create' method. - ''' - self.ensure_one() - - if not counterpart_account_id: - counterpart_account_id = self.journal_id.suspense_account_id.id - - if not counterpart_account_id: - raise UserError(_( - "You can't create a new statement line without a suspense account set on the %s journal.", - self.journal_id.display_name, - )) - - journal = self.journal_id - company_currency = journal.company_id.currency_id - journal_currency = journal.currency_id or company_currency - foreign_currency = self.foreign_currency_id or journal_currency or company_currency - - journal_amount = self.amount - if foreign_currency == journal_currency: - transaction_amount = journal_amount - else: - transaction_amount = self.amount_currency - if journal_currency == company_currency: - company_amount = journal_amount - elif foreign_currency == company_currency: - company_amount = transaction_amount - else: - company_amount = journal_currency._convert(journal_amount, company_currency, journal.company_id, self.date) - - liquidity_line_vals = { - 'name': self.payment_ref, - 'move_id': self.move_id.id, - 'partner_id': self.partner_id.id, - 'account_id': journal.default_account_id.id, - 'currency_id': journal_currency.id, - 'amount_currency': journal_amount, - 'debit': company_amount > 0 and company_amount or 0.0, - 'credit': company_amount < 0 and -company_amount or 0.0, - } - - # Create the counterpart line values. - counterpart_line_vals = { - 'name': self.payment_ref, - 'account_id': counterpart_account_id, - 'move_id': self.move_id.id, - 'partner_id': self.partner_id.id, - 'currency_id': foreign_currency.id, - 'amount_currency': -transaction_amount, - 'debit': -company_amount if company_amount < 0.0 else 0.0, - 'credit': company_amount if company_amount > 0.0 else 0.0, - } - return [liquidity_line_vals, counterpart_line_vals] + attachment_ids = fields.Many2many( + comodel_name='ir.attachment' + ) # ------------------------------------------------------------------------- # COMPUTE METHODS # ------------------------------------------------------------------------- + @api.depends('line_ids.internal_index') + def _compute_date_index(self): + for stmt in self: + sorted_lines = stmt.line_ids.sorted('internal_index') + stmt.date = sorted_lines[-1:].date + stmt.first_line_index = sorted_lines[:1].internal_index - @api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency', - 'move_id.to_check', - 'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency', - 'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id', - 'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids') - def _compute_is_reconciled(self): - ''' Compute the field indicating if the statement lines are already reconciled with something. - This field is used for display purpose (e.g. display the 'cancel' button on the statement lines). - Also computes the residual amount of the statement line. - ''' - for st_line in self: - liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() + @api.depends('balance_start', 'line_ids.amount') + def _compute_balance_end(self): + for statement in self: + statement.balance_end = statement.balance_start + sum(statement.line_ids.mapped('amount')) - # Compute residual amount - if st_line.to_check: - st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount - elif suspense_lines.account_id.reconcile: - st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency')) - else: - st_line.amount_residual = sum(suspense_lines.mapped('amount_currency')) + @api.depends('journal_id') + def _compute_currency_id(self): + for statement in self: + statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id - # Compute is_reconciled - if not st_line.id: - # New record: The journal items are not yet there. - st_line.is_reconciled = False - elif suspense_lines: - # In case of the statement line comes from an older version, it could have a residual amount of zero. - st_line.is_reconciled = suspense_lines.currency_id.is_zero(st_line.amount_residual) - elif st_line.currency_id.is_zero(st_line.amount): - st_line.is_reconciled = True - else: - # The journal entry seems reconciled. - st_line.is_reconciled = True + @api.depends('line_ids.journal_id') + def _compute_journal_id(self): + for statement in self: + statement.journal_id = statement.line_ids.journal_id + + @api.depends('balance_end_real', 'balance_end') + def _compute_is_complete(self): + for stmt in self: + stmt.is_complete = stmt.line_ids and stmt.currency_id.compare_amounts( + stmt.balance_end, stmt.balance_end_real) == 0 + + def _compute_is_valid(self): + # we extract the invalid statements, the statements with no lines and the first statement are not in the query + # because they don't have a previous statement, so they are excluded from the join, and we consider them valid. + # if we have extracted the valid ones, we would have to mark above-mentioned statements valid manually + invalids = self.filtered(lambda s: s.id in self._get_invalid_statement_ids()) + invalids.is_valid = False + (self - invalids).is_valid = True + + def _search_is_valid(self, operator, value): + if operator not in ('=', '!=', '<>'): + raise UserError(_('Operation not supported')) + invalid_ids = self._get_invalid_statement_ids(all_statements=True) + if operator in ('!=', '<>') and value or operator == '=' and not value: + return [('id', 'in', invalid_ids)] + return [('id', 'not in', invalid_ids)] # ------------------------------------------------------------------------- - # CONSTRAINT METHODS + # CRUD # ------------------------------------------------------------------------- - - @api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id') - def _check_amounts_currencies(self): - ''' Ensure the consistency the specified amounts and the currencies. ''' - - for st_line in self: - if st_line.journal_id != st_line.statement_id.journal_id: - raise ValidationError(_('The journal of a statement line must always be the same as the bank statement one.')) - if st_line.foreign_currency_id == st_line.currency_id: - raise ValidationError(_("The foreign currency must be different than the journal one: %s", st_line.currency_id.name)) - if not st_line.foreign_currency_id and st_line.amount_currency: - raise ValidationError(_("You can't provide an amount in foreign currency without specifying a foreign currency.")) - if not st_line.amount_currency and st_line.foreign_currency_id: - raise ValidationError(_("You can't provide a foreign currency without specifying an amount in 'Amount in Currency' field.")) - - # ------------------------------------------------------------------------- - # LOW-LEVEL METHODS - # ------------------------------------------------------------------------- - @api.model_create_multi def create(self, vals_list): - # OVERRIDE - counterpart_account_ids = [] - - for vals in vals_list: - statement = self.env['account.bank.statement'].browse(vals['statement_id']) - if statement.state != 'open' and self._context.get('check_move_validity', True): - raise UserError(_("You can only create statement line in open bank statements.")) - - # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context. - vals['move_type'] = 'entry' - - journal = statement.journal_id - # Ensure the journal is the same as the statement one. - vals['journal_id'] = journal.id - vals['company_id'] = journal.company_id.id - vals['currency_id'] = (journal.currency_id or journal.company_id.currency_id).id - if 'date' not in vals: - vals['date'] = statement.date - - # Hack to force different account instead of the suspense account. - counterpart_account_ids.append(vals.pop('counterpart_account_id', None)) - - st_lines = super().create(vals_list) - - for i, st_line in enumerate(st_lines): - counterpart_account_id = counterpart_account_ids[i] - - to_write = {'statement_line_id': st_line.id, 'narration': st_line.narration} - if 'line_ids' not in vals_list[i]: - to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(counterpart_account_id=counterpart_account_id)] - - st_line.move_id.write(to_write) - - # Otherwise field narration will be recomputed silently (at next flush) when writing on partner_id - self.env.remove_to_compute(st_line.move_id._fields['narration'], st_line.move_id) - return st_lines - - def write(self, vals): - # OVERRIDE - res = super().write(vals) - self._synchronize_to_moves(set(vals.keys())) - return res - - def unlink(self): - # OVERRIDE to unlink the inherited account.move (move_id field) as well. - moves = self.with_context(force_delete=True).mapped('move_id') - res = super().unlink() - moves.unlink() - return res - - # ------------------------------------------------------------------------- - # SYNCHRONIZATION account.bank.statement.line <-> account.move - # ------------------------------------------------------------------------- - - def _synchronize_from_moves(self, changed_fields): - ''' Update the account.bank.statement.line regarding its related account.move. - Also, check both models are still consistent. - :param changed_fields: A set containing all modified fields on account.move. - ''' - if self._context.get('skip_account_move_synchronization'): - return - - for st_line in self.with_context(skip_account_move_synchronization=True): - move = st_line.move_id - move_vals_to_write = {} - st_line_vals_to_write = {} - - if 'state' in changed_fields: - if (st_line.state == 'open' and move.state != 'draft') or (st_line.state in ('posted', 'confirm') and move.state != 'posted'): - raise UserError(_( - "You can't manually change the state of journal entry %s, as it has been created by bank " - "statement %s." - ) % (st_line.move_id.display_name, st_line.statement_id.display_name)) - - if 'line_ids' in changed_fields: - liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() - company_currency = st_line.journal_id.company_id.currency_id - journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False - - if len(liquidity_lines) != 1: - raise UserError(_( - "The journal entry %s reached an invalid state regarding its related statement line.\n" - "To be consistent, the journal entry must always have exactly one journal item involving the " - "bank/cash account." - ) % st_line.move_id.display_name) - - st_line_vals_to_write.update({ - 'payment_ref': liquidity_lines.name, - 'partner_id': liquidity_lines.partner_id.id, - }) - - # Update 'amount' according to the liquidity line. - - if journal_currency: - st_line_vals_to_write.update({ - 'amount': liquidity_lines.amount_currency, - }) - else: - st_line_vals_to_write.update({ - 'amount': liquidity_lines.balance, - }) - - if len(suspense_lines) == 1: - - if journal_currency and suspense_lines.currency_id == journal_currency: - - # The suspense line is expressed in the journal's currency meaning the foreign currency - # set on the statement line is no longer needed. - - st_line_vals_to_write.update({ - 'amount_currency': 0.0, - 'foreign_currency_id': False, - }) - - elif not journal_currency and suspense_lines.currency_id == company_currency: - - # Don't set a specific foreign currency on the statement line. - - st_line_vals_to_write.update({ - 'amount_currency': 0.0, - 'foreign_currency_id': False, - }) - - else: - - # Update the statement line regarding the foreign currency of the suspense line. - - st_line_vals_to_write.update({ - 'amount_currency': -suspense_lines.amount_currency, - 'foreign_currency_id': suspense_lines.currency_id.id, - }) - - move_vals_to_write.update({ - 'partner_id': liquidity_lines.partner_id.id, - 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, - }) - - move.write(move._cleanup_write_orm_values(move, move_vals_to_write)) - st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write)) - - def _synchronize_to_moves(self, changed_fields): - ''' Update the account.move regarding the modified account.bank.statement.line. - :param changed_fields: A list containing all modified fields on account.bank.statement.line. - ''' - if self._context.get('skip_account_move_synchronization'): - return - - if not any(field_name in changed_fields for field_name in ( - 'payment_ref', 'amount', 'amount_currency', - 'foreign_currency_id', 'currency_id', 'partner_id', - )): - return - - for st_line in self.with_context(skip_account_move_synchronization=True): - liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() - journal = st_line.statement_id.journal_id - company_currency = journal.company_id.currency_id - journal_currency = journal.currency_id if journal.currency_id != company_currency else False - - line_vals_list = st_line._prepare_move_line_default_vals() - line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])] - - if suspense_lines: - line_ids_commands.append((1, suspense_lines.id, line_vals_list[1])) - else: - line_ids_commands.append((0, 0, line_vals_list[1])) - - for line in other_lines: - line_ids_commands.append((2, line.id)) - - st_line_vals = { - 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, - 'line_ids': line_ids_commands, - } - if st_line.move_id.journal_id != journal: - st_line_vals['journal_id'] = journal.id - if st_line.move_id.partner_id != st_line.partner_id: - st_line_vals['partner_id'] = st_line.partner_id.id - st_line.move_id.write(st_line_vals) + # EXTENDS base + # If we are doing a split, we have to correct the split statement's balance to keep both original and new + # statements complete and valid. + if self._context.get('split_line_id'): + old_statement = self.env['account.bank.statement.line'].browse(self._context.get('split_line_id')).statement_id + old_lines = old_statement.line_ids + statements = super().create(vals_list) + if self._context.get('split_line_id'): + statements.ensure_one() + if old_statement: + net_change = sum((statements.line_ids & old_lines).mapped('amount')) + old_statement.balance_start += net_change + return statements # ------------------------------------------------------------------------- # BUSINESS METHODS # ------------------------------------------------------------------------- + def _get_invalid_statement_ids(self, all_statements=None): + """ Returns the statements that are invalid for _compute and _search methods.""" - def _get_st_line_strings_for_matching(self, allowed_fields=None): - """ Collect the strings that could be used on the statement line to perform some matching. + self.line_ids.flush_model(['statement_id', 'internal_index']) + self.flush_model(['balance_start', 'balance_end_real', 'first_line_index']) - :param allowed_fields: A explicit list of fields to consider. - :return: A list of strings. - """ - self.ensure_one() - - def _get_text_value(field_name): - if self._fields[field_name].type == 'html': - return html2plaintext(self[field_name]) - else: - return self[field_name] - - st_line_text_values = [] - if allowed_fields is None or 'payment_ref' in allowed_fields: - value = _get_text_value('payment_ref') - if value: - st_line_text_values.append(value) - if allowed_fields is None or 'narration' in allowed_fields: - value = _get_text_value('narration') - if value: - st_line_text_values.append(value) - if allowed_fields is None or 'ref' in allowed_fields: - value = _get_text_value('ref') - if value: - st_line_text_values.append(value) - return st_line_text_values - - def _get_default_amls_matching_domain(self): - return [ - # Base domain. - ('display_type', 'not in', ('line_section', 'line_note')), - ('parent_state', '=', 'posted'), - ('company_id', '=', self.company_id.id), - # Reconciliation domain. - ('reconciled', '=', False), - ('account_id.reconcile', '=', True), - # Special domain for payments. - '|', - ('account_id.account_type', 'not in', ('asset_receivable', 'liability_payable')), - ('payment_id', '=', False), - # Special domain for statement lines. - ('statement_line_id', '!=', self.id), - ] - - def _retrieve_partner(self): - self.ensure_one() - - # Retrieve the partner from the statement line. - if self.partner_id: - return self.partner_id - - # Retrieve the partner from the bank account. - if self.account_number: - account_number_nums = sanitize_account_number(self.account_number) - if account_number_nums: - domain = [('sanitized_acc_number', 'ilike', account_number_nums)] - for extra_domain in ([('company_id', '=', self.company_id.id)], []): - bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain) - if len(bank_accounts.partner_id) == 1: - return bank_accounts.partner_id - - # Retrieve the partner from the partner name. - if self.partner_name: - domain = [ - ('parent_id', '=', False), - ('name', 'ilike', self.partner_name), - ] - for extra_domain in ([('company_id', '=', self.company_id.id)], []): - partner = self.env['res.partner'].search(extra_domain + domain, limit=1) - if partner: - return partner - - # Retrieve the partner from the reconcile models. - rec_models = self.env['account.reconcile.model'].search([ - ('rule_type', '!=', 'writeoff_button'), - ('company_id', '=', self.company_id.id), - ]) - for rec_model in rec_models: - partner = rec_model._get_partner_from_mapping(self) - if partner and rec_model._is_applicable_for(self, partner): - return partner - - # Retrieve the partner from statement line text values. - st_line_text_values = self._get_st_line_strings_for_matching() - unaccent = get_unaccent_wrapper(self._cr) - sub_queries = [] - params = [] - for text_value in st_line_text_values: - if not text_value: - continue - - # Find a partner having a name contained inside the statement line values. - # Take care a partner could contain some special characters in its name that needs to be escaped. - sub_queries.append(rf''' - {unaccent("%s")} ~* ('^' || ( - SELECT STRING_AGG(CONCAT('(?=.*\m', chunk[1], '\M)'), '') - FROM regexp_matches({unaccent('name')}, '\w{{3,}}', 'g') AS chunk - )) - ''') - params.append(text_value) - - if sub_queries: - self.env['res.partner'].flush_model(['company_id', 'name']) - self._cr.execute( - ''' - SELECT id - FROM res_partner - WHERE (company_id IS NULL OR company_id = %s) - AND name IS NOT NULL - AND (''' + ') OR ('.join(sub_queries) + ''') - ''', - [self.company_id.id] + params, - ) - rows = self._cr.fetchall() - if len(rows) == 1: - return self.env['res.partner'].browse(rows[0][0]) - - return self.env['res.partner'] - - def _find_or_create_bank_account(self): - bank_account = self.env['res.partner.bank'].search([ - ('acc_number', '=', self.account_number), - ('partner_id', '=', self.partner_id.id), - ]) - if not bank_account: - bank_account = self.env['res.partner.bank'].create({ - 'acc_number': self.account_number, - 'partner_id': self.partner_id.id, - }) - return bank_account - - def button_undo_reconciliation(self): - ''' Undo the reconciliation mades on the statement line and reset their journal items - to their original states. - ''' - self.line_ids.remove_move_reconcile() - self.payment_ids.unlink() - - for st_line in self: - st_line.with_context(force_delete=True).write({ - 'to_check': False, - 'line_ids': [(5, 0)] + [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals()], - }) - -# For optimization purpose, creating the reverse relation of m2o in _inherits saves -# a lot of SQL queries -class AccountMove(models.Model): - _name = "account.move" - _inherit = ['account.move'] - - statement_line_ids = fields.One2many('account.bank.statement.line', 'move_id', string='Statements') + self._cr.execute(''' + SELECT id + FROM account_bank_statement st, + LATERAL ( + SELECT balance_end_real + FROM account_bank_statement st_lookup + WHERE st_lookup.first_line_index < st.first_line_index + AND st_lookup.journal_id = st.journal_id + ORDER BY st_lookup.first_line_index desc + LIMIT 1 ) prev + WHERE prev.balance_end_real != st.balance_start + ''' + ('AND st.id IN %s' if all_statements else ''), (tuple(self.ids),)) + res = self.env.cr.fetchall() + return [r[0] for r in res] diff --git a/addons/account/models/account_bank_statement_line.py b/addons/account/models/account_bank_statement_line.py new file mode 100644 index 00000000000..49f999c1e4e --- /dev/null +++ b/addons/account/models/account_bank_statement_line.py @@ -0,0 +1,806 @@ +from odoo import api, Command, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import html2plaintext +from odoo.osv.expression import get_unaccent_wrapper + +from odoo.addons.base.models.res_bank import sanitize_account_number + +from xmlrpc.client import MAXINT + + +class AccountBankStatementLine(models.Model): + _name = "account.bank.statement.line" + _inherits = {'account.move': 'move_id'} + _description = "Bank Statement Line" + _order = "internal_index desc" + _check_company_auto = True + + # FIXME: Field having the same name in both tables are confusing (partner_id). We don't change it because: + # - It's a mess to track/fix. + # - Some fields here could be simplified when the onchanges will be gone in account.move. + # Should be improved in the future. + # - there should be a better way for syncing account_moves with bank transactions, payments, invoices, etc. + + # == Business fields == + def default_get(self, fields_list): + defaults = super().default_get(fields_list) + # copy the date and statement from the latest transaction of the same journal to help the user + # to enter the next transaction, they do not have to enter the date and the statement every time until the + # statement is completed. It is only possible if we know the journal that is used, so it can only be done + # in a view in which the journal is already set and so is single journal view. + if'journal_id' in defaults: + last_line = self.search([('journal_id', '=', defaults.get('journal_id'))], limit=1) + statement = last_line.statement_id + if statement and not statement.is_complete: + defaults.setdefault( + 'statement_id', statement.id + ) + if statement.date: + defaults.setdefault( + 'date', statement.date + ) + + return defaults + + move_id = fields.Many2one( + comodel_name='account.move', + auto_join=True, + string='Journal Entry', required=True, readonly=True, ondelete='cascade', + check_company=True) + statement_id = fields.Many2one( + comodel_name='account.bank.statement', + string='Statement', + ) + + # Payments generated during the reconciliation of this bank statement lines. + payment_ids = fields.Many2many( + comodel_name='account.payment', + relation='account_payment_account_bank_statement_line_rel', + string='Auto-generated Payments', + ) + + # This sequence is working reversed because the default order is reversed, more info in compute_internal_index + sequence = fields.Integer(default=1) + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Partner', ondelete='restrict', + domain="['|', ('parent_id','=', False), ('is_company','=',True)]", + check_company=True) + + # Technical field used to store the bank account number before its creation, upon the line's processing + account_number = fields.Char(string='Bank Account Number') + + # This field is used to record the third party name when importing bank statement in electronic format, + # when the partner doesn't exist yet in the database (or cannot be found). + partner_name = fields.Char() + + # Transaction type is used in electronic format, when the type of transaction is available in the imported file. + transaction_type = fields.Char() + payment_ref = fields.Char(string='Label') + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Journal Currency', + compute='_compute_currency_id', store=True, + ) + amount = fields.Monetary() + + # Note the values of this field does not necessarily correspond to the cumulated balance in the account move line. + # here these values correspond to occurrence order (the reality) and they should match the bank report but in + # the move lines, it corresponds to the recognition order. Also, the statements act as checkpoints on this field + running_balance = fields.Monetary( + compute='_compute_running_balance' + ) + foreign_currency_id = fields.Many2one( + comodel_name='res.currency', + string="Foreign Currency", + help="The optional other currency if it is a multi-currency entry.", + ) + amount_currency = fields.Monetary( + string="Amount in Currency", + currency_field='foreign_currency_id', + help="The amount expressed in an optional other currency if it is a multi-currency entry.", + ) + + # == Technical fields == + # The amount left to be reconciled on this statement line (signed according to its move lines' balance), + # expressed in its currency. This is a technical field use to speed up the application of reconciliation models. + amount_residual = fields.Float( + string="Residual Amount", + compute="_compute_is_reconciled", + store=True, + ) + country_code = fields.Char( + related='company_id.account_fiscal_country_id.code' + ) + + # Technical field used to store the internal reference of the statement line for fast indexing and easier comparing + # of statement lines. It holds the combination of the date, sequence and id of each line. Without this field, + # the search/sorting lines would be very slow. The date field is related and stored in the account.move model, + # so it is not possible to have an index on it (unless we use a sql view which is too complicated). + # Using this prevents us having a compound index, and extensive `where` clauses. + # Without this finding lines before current line (which we need e.g. for calculating the running balance) + # would need a query like this: + # date < current date OR (date = current date AND sequence > current date) or ( + # date = current date AND sequence = current sequence AND id < current id) + # which needs to be repeated all over the code. + # This would be simply "internal index < current internal index" using this field. + internal_index = fields.Char( + string='Internal Reference', + compute='_compute_internal_index', store=True, + index=True, + ) + + # Technical field indicating if the statement line is already reconciled. + is_reconciled = fields.Boolean( + string='Is Reconciled', + compute='_compute_is_reconciled', store=True, + ) + statement_complete = fields.Boolean( + related='statement_id.is_complete', + ) + statement_valid = fields.Boolean( + related='statement_id.is_valid', + ) + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('journal_id.currency_id') + def _compute_currency_id(self): + for st_line in self: + st_line.currency_id = st_line.journal_id.currency_id or st_line.company_id.currency_id + + def _compute_running_balance(self): + # It looks back to find the latest statement and uses its balance_start as an anchor point for calculation, so + # that the running balance is always relative to the latest statement. In this way we do not need to calculate + # the running balance for all statement lines every time. + # If there are statements inside the computed range, their balance_start has priority over calculated balance. + + self.statement_id.flush_model(['balance_start', 'first_line_index']) + self.flush_model(['internal_index', 'date', 'journal_id', 'statement_id', 'amount']) + record_by_id = {x.id: x for x in self} + + for journal in self.journal_id: + journal_lines = self.filtered(lambda line: line.journal_id == journal).sorted('internal_index') + max_index = max(journal_lines.mapped('internal_index')) + min_index = min(journal_lines.mapped('internal_index')) + + # Find the oldest index for each journal. + self._cr.execute( + """ + SELECT first_line_index, balance_start + FROM account_bank_statement + WHERE + first_line_index < %s + AND journal_id = %s + ORDER BY first_line_index DESC + LIMIT 1 + """, + [min_index, journal.id], + ) + current_running_balance = 0.0 + extra_clause = '' + extra_params = [] + row = self._cr.fetchone() + if row: + starting_index, current_running_balance = row + extra_clause = "AND st_line.internal_index >= %s" + extra_params.append(starting_index) + + self._cr.execute( + f""" + SELECT + st_line.id, + st_line.amount, + st.first_line_index = st_line.internal_index AS is_anchor, + st.balance_start + FROM account_bank_statement_line st_line + JOIN account_move move ON move.statement_line_id = st_line.id + LEFT JOIN account_bank_statement st ON st.id = st_line.statement_id + WHERE + st_line.internal_index <= %s + AND move.journal_id = %s + {extra_clause} + ORDER BY st_line.internal_index + """, + [max_index, journal.id] + extra_params, + ) + for st_line_id, amount, is_anchor, balance_start in self._cr.fetchall(): + if is_anchor: + current_running_balance = balance_start + current_running_balance += amount + if record_by_id.get(st_line_id): + record_by_id[st_line_id].running_balance = current_running_balance + + @api.depends('date', 'sequence') + def _compute_internal_index(self): + """ + Internal index is a field that holds the combination of the date, compliment of sequence and id of each line. + Using this prevents us having a compound index, and extensive where clauses. + Without this finding lines before current line (which we need for calculating the running balance) + would need a query like this: + date < current date OR (date = current date AND sequence > current date) or ( + date = current date AND sequence = current sequence AND id < current id) + which needs to be repeated all over the code. + This would be simply "internal index < current internal index" using this field. + Also, we would need a compound index of date + sequence + id + on the table which is not possible because date is not in this table (it is in the account move table) + unless we use a sql view which is more complicated. + """ + # ensure we are using correct value for reversing sequence in the index (2147483647) + # NOTE: assert self._fields['sequence'].column_type[1] == 'int4' + # if for any reason it changes (how unlikely), we need to update this code + + for st_line in self.filtered(lambda line: line._origin.id): + st_line.internal_index = f'{st_line.date.strftime("%Y%m%d")}' \ + f'{MAXINT - st_line.sequence:0>10}' \ + f'{st_line._origin.id:0>10}' + + @api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency', + 'move_id.to_check', + 'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency', + 'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id', + 'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids') + def _compute_is_reconciled(self): + """ Compute the field indicating if the statement lines are already reconciled with something. + This field is used for display purpose (e.g. display the 'cancel' button on the statement lines). + Also computes the residual amount of the statement line. + """ + for st_line in self: + _liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines() + + # Compute residual amount + if st_line.to_check: + st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount + elif suspense_lines.account_id.reconcile: + st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency')) + else: + st_line.amount_residual = sum(suspense_lines.mapped('amount_currency')) + + # Compute is_reconciled + if not st_line.id: + # New record: The journal items are not yet there. + st_line.is_reconciled = False + elif suspense_lines: + # In case of the statement line comes from an older version, it could have a residual amount of zero. + st_line.is_reconciled = suspense_lines.currency_id.is_zero(st_line.amount_residual) + elif st_line.currency_id.is_zero(st_line.amount): + st_line.is_reconciled = True + else: + # The journal entry seems reconciled. + st_line.is_reconciled = True + + @api.onchange('journal_id') + def _onchange_journal_id(self): + """ + Reset the statement line when the journal is changed. In some rare cases that journal is not in the context + the journal_id field might be accessible to the user. In this cse we need to reset the statement_id field when + the journal_id is changed. + :return: + """ + self.statement_id = self._get_default_statement(self.journal_id.id, self.date) + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + + @api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id') + def _check_amounts_currencies(self): + """ Ensure the consistency the specified amounts and the currencies. """ + + for st_line in self: + if st_line.foreign_currency_id == st_line.currency_id: + raise ValidationError(_("The foreign currency must be different than the journal one: %s", + st_line.currency_id.name)) + if not st_line.foreign_currency_id and st_line.amount_currency: + raise ValidationError(_("You can't provide an amount in foreign currency without " + "specifying a foreign currency.")) + if not st_line.amount_currency and st_line.foreign_currency_id: + raise ValidationError(_("You can't provide a foreign currency without specifying an amount in " + "'Amount in Currency' field.")) + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + def new(self, values=None, origin=None, ref=None): + st_line = super().new(values, origin, ref) + if not st_line.journal_id: # might not be computed because declared by inheritance + st_line.move_id._compute_journal_id() + return st_line + + @api.model_create_multi + def create(self, vals_list): + # OVERRIDE + counterpart_account_ids = [] + + for vals in vals_list: + if 'statement_id' in vals and 'journal_id' not in vals: + statement = self.env['account.bank.statement'].browse(vals['statement_id']) + # Ensure the journal is the same as the statement one. + # journal_id is a required field in the view, so it should be always available if the user + # is creating the record, however, if a sync/import modules tries to add a line to an existing + # statement they can omit the journal field because it can be obtained from the statement + if statement.journal_id: + vals['journal_id'] = statement.journal_id.id + + # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context. + vals['move_type'] = 'entry' + + # Hack to force different account instead of the suspense account. + counterpart_account_ids.append(vals.pop('counterpart_account_id', None)) + + st_lines = super().create(vals_list) + + for i, st_line in enumerate(st_lines): + counterpart_account_id = counterpart_account_ids[i] + + to_write = {'statement_line_id': st_line.id, 'narration': st_line.narration} + if 'line_ids' not in vals_list[i]: + to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals( + counterpart_account_id=counterpart_account_id)] + + st_line.move_id.write(to_write) + + # Otherwise field narration will be recomputed silently (at next flush) when writing on partner_id + self.env.remove_to_compute(st_line.move_id._fields['narration'], st_line.move_id) + + # No need for the user to manage their status (from 'Draft' to 'Posted') + st_lines.move_id.action_post() + return st_lines + + def write(self, vals): + # OVERRIDE + + res = super().write(vals) + self._synchronize_to_moves(set(vals.keys())) + return res + + def unlink(self): + # OVERRIDE to unlink the inherited account.move (move_id field) as well. + moves = self.with_context(force_delete=True).mapped('move_id') + res = super().unlink() + moves.unlink() + return res + + # ------------------------------------------------------------------------- + # ACTION METHODS + # ------------------------------------------------------------------------- + + def action_undo_reconciliation(self): + """ Undo the reconciliation made on the statement line and reset their journal items + to their original states. + """ + self.line_ids.remove_move_reconcile() + self.payment_ids.unlink() + + for st_line in self: + st_line.with_context(force_delete=True).write({ + 'to_check': False, + 'line_ids': [Command.clear()] + [ + Command.create(line_vals) for line_vals in st_line._prepare_move_line_default_vals()], + }) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _find_or_create_bank_account(self): + bank_account = self.env['res.partner.bank'].search([ + ('acc_number', '=', self.account_number), + ('partner_id', '=', self.partner_id.id), + ]) + if not bank_account: + bank_account = self.env['res.partner.bank'].create({ + 'acc_number': self.account_number, + 'partner_id': self.partner_id.id, + }) + return bank_account + + def _get_amounts_with_currencies(self): + """ + Returns the line amount in company, journal and foreign currencies + """ + self.ensure_one() + + company_currency = self.journal_id.company_id.currency_id + journal_currency = self.journal_id.currency_id or company_currency + foreign_currency = self.foreign_currency_id or journal_currency or company_currency + + journal_amount = self.amount + if foreign_currency == journal_currency: + transaction_amount = journal_amount + else: + transaction_amount = self.amount_currency + if journal_currency == company_currency: + company_amount = journal_amount + elif foreign_currency == company_currency: + company_amount = transaction_amount + else: + company_amount = journal_currency._convert(journal_amount, company_currency, + self.journal_id.company_id, self.date) + return company_amount, company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency + + def _get_default_amls_matching_domain(self): + return [ + # Base domain. + ('display_type', 'not in', ('line_section', 'line_note')), + ('parent_state', '=', 'posted'), + ('company_id', '=', self.company_id.id), + # Reconciliation domain. + ('reconciled', '=', False), + ('account_id.reconcile', '=', True), + # Special domain for payments. + '|', + ('account_id.account_type', 'not in', ('asset_receivable', 'liability_payable')), + ('payment_id', '=', False), + # Special domain for statement lines. + ('statement_line_id', '!=', self.id), + ] + + @api.model + def _get_default_journal(self): + journal_type = self.env.context.get('journal_type', 'bank') + return self.env['account.journal'].search([ + ('type', '=', journal_type), + ('company_id', '=', self.env.company.id) + ], limit=1) + + @api.model + def _get_default_statement(self, journal_id=None, date=None): + statement = self.search( + domain=[ + ('journal_id', '=', journal_id or self._get_default_journal().id), + ('date', '<=', date or fields.Date.today()), + ], + limit=1 + ).statement_id + if not statement.is_complete: + return statement + + def _get_st_line_strings_for_matching(self, allowed_fields=None): + """ Collect the strings that could be used on the statement line to perform some matching. + + :param allowed_fields: A explicit list of fields to consider. + :return: A list of strings. + """ + self.ensure_one() + + def _get_text_value(field_name): + if self._fields[field_name].type == 'html': + return self[field_name] and html2plaintext(self[field_name]) + else: + return self[field_name] + + st_line_text_values = [] + if allowed_fields is None or 'payment_ref' in allowed_fields: + value = _get_text_value('payment_ref') + if value: + st_line_text_values.append(value) + if allowed_fields is None or 'narration' in allowed_fields: + value = _get_text_value('narration') + if value: + st_line_text_values.append(value) + if allowed_fields is None or 'ref' in allowed_fields: + value = _get_text_value('ref') + if value: + st_line_text_values.append(value) + return st_line_text_values + + def _prepare_counterpart_amounts_using_st_line_rate(self, currency, balance, amount_currency): + """ Convert the amounts passed as parameters to the statement line currency using the rates provided by the + bank. The computed amounts are the one that could be set on the statement line as a counterpart journal item + to fully paid the provided amounts as parameters. + + :param currency: The currency in which is expressed 'amount_currency'. + :param balance: The amount expressed in company currency. Only needed when the currency passed as + parameter is neither the statement line's foreign currency, neither the journal's + currency. + :param amount_currency: The amount expressed in the 'currency' passed as parameter. + :return: A python dictionary containing: + * balance: The amount to consider expressed in company's currency. + * amount_currency: The amount to consider expressed in statement line's foreign currency. + """ + self.ensure_one() + company_amount, company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency \ + = self._get_amounts_with_currencies() + + rate_journal2foreign_curr = journal_amount and abs(transaction_amount) / abs(journal_amount) + rate_comp2journal_curr = company_amount and abs(journal_amount) / abs(company_amount) + + if currency == foreign_currency: + trans_amount_currency = amount_currency + if rate_journal2foreign_curr: + journ_amount_currency = journal_currency.round(trans_amount_currency / rate_journal2foreign_curr) + else: + journ_amount_currency = 0.0 + if rate_comp2journal_curr: + new_balance = company_currency.round(journ_amount_currency / rate_comp2journal_curr) + else: + new_balance = 0.0 + elif currency == journal_currency: + trans_amount_currency = foreign_currency.round(amount_currency * rate_journal2foreign_curr) + if rate_comp2journal_curr: + new_balance = company_currency.round(amount_currency / rate_comp2journal_curr) + else: + new_balance = 0.0 + else: + journ_amount_currency = journal_currency.round(balance * rate_comp2journal_curr) + trans_amount_currency = foreign_currency.round(journ_amount_currency * rate_journal2foreign_curr) + new_balance = balance + + return { + 'amount_currency': trans_amount_currency, + 'balance': new_balance, + } + + def _prepare_move_line_default_vals(self, counterpart_account_id=None): + """ Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line + record. + :return: A list of python dictionary to be passed to the account.move.line's 'create' method. + """ + self.ensure_one() + + if not counterpart_account_id: + counterpart_account_id = self.journal_id.suspense_account_id.id + + if not counterpart_account_id: + raise UserError(_( + "You can't create a new statement line without a suspense account set on the %s journal.", + self.journal_id.display_name, + )) + + company_amount, _company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency \ + = self._get_amounts_with_currencies() + + liquidity_line_vals = { + 'name': self.payment_ref, + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'account_id': self.journal_id.default_account_id.id, + 'currency_id': journal_currency.id, + 'amount_currency': journal_amount, + 'debit': company_amount > 0 and company_amount or 0.0, + 'credit': company_amount < 0 and -company_amount or 0.0, + } + + # Create the counterpart line values. + counterpart_line_vals = { + 'name': self.payment_ref, + 'account_id': counterpart_account_id, + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'currency_id': foreign_currency.id, + 'amount_currency': -transaction_amount, + 'debit': -company_amount if company_amount < 0.0 else 0.0, + 'credit': company_amount if company_amount > 0.0 else 0.0, + } + return [liquidity_line_vals, counterpart_line_vals] + + def _retrieve_partner(self): + self.ensure_one() + + # Retrieve the partner from the statement line. + if self.partner_id: + return self.partner_id + + # Retrieve the partner from the bank account. + if self.account_number: + account_number_nums = sanitize_account_number(self.account_number) + if account_number_nums: + domain = [('sanitized_acc_number', 'ilike', account_number_nums)] + for extra_domain in ([('company_id', '=', self.company_id.id)], []): + bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain) + if len(bank_accounts.partner_id) == 1: + return bank_accounts.partner_id + + # Retrieve the partner from the partner name. + if self.partner_name: + domain = [ + ('parent_id', '=', False), + ('name', 'ilike', self.partner_name), + ] + for extra_domain in ([('company_id', '=', self.company_id.id)], []): + partner = self.env['res.partner'].search(extra_domain + domain, limit=1) + if partner: + return partner + + # Retrieve the partner from the 'reconcile models'. + rec_models = self.env['account.reconcile.model'].search([ + ('rule_type', '!=', 'writeoff_button'), + ('company_id', '=', self.company_id.id), + ]) + for rec_model in rec_models: + partner = rec_model._get_partner_from_mapping(self) + if partner and rec_model._is_applicable_for(self, partner): + return partner + + # Retrieve the partner from statement line text values. + st_line_text_values = self._get_st_line_strings_for_matching() + unaccent = get_unaccent_wrapper(self._cr) + sub_queries = [] + params = [] + for text_value in st_line_text_values: + if not text_value: + continue + + # Find a partner having a name contained inside the statement line values. + # Take care a partner could contain some special characters in its name that needs to be escaped. + sub_queries.append(rf''' + {unaccent("%s")} ~* ('^' || ( + SELECT STRING_AGG(CONCAT('(?=.*\m', chunk[1], '\M)'), '') + FROM regexp_matches({unaccent('name')}, '\w{{3,}}', 'g') AS chunk + )) + ''') + params.append(text_value) + + if sub_queries: + self.env['res.partner'].flush_model(['company_id', 'name']) + self._cr.execute( + ''' + SELECT id + FROM res_partner + WHERE (company_id IS NULL OR company_id = %s) + AND name IS NOT NULL + AND (''' + ') OR ('.join(sub_queries) + ''') + ''', + [self.company_id.id] + params, + ) + rows = self._cr.fetchall() + if len(rows) == 1: + return self.env['res.partner'].browse(rows[0][0]) + + return self.env['res.partner'] + + def _seek_for_lines(self): + """ Helper used to dispatch the journal items between: + - The lines using the liquidity account. + - The lines using the transfer account. + - The lines being not in one of the two previous categories. + :return: (liquidity_lines, suspense_lines, other_lines) + """ + liquidity_lines = self.env['account.move.line'] + suspense_lines = self.env['account.move.line'] + other_lines = self.env['account.move.line'] + + for line in self.move_id.line_ids: + if line.account_id == self.journal_id.default_account_id: + liquidity_lines += line + elif line.account_id == self.journal_id.suspense_account_id: + suspense_lines += line + else: + other_lines += line + return liquidity_lines, suspense_lines, other_lines + + # SYNCHRONIZATION account.bank.statement.line <-> account.move + # ------------------------------------------------------------------------- + + def _synchronize_from_moves(self, changed_fields): + """ Update the account.bank.statement.line regarding its related account.move. + Also, check both models are still consistent. + :param changed_fields: A set containing all modified fields on account.move. + """ + if self._context.get('skip_account_move_synchronization'): + return + + for st_line in self.with_context(skip_account_move_synchronization=True): + move = st_line.move_id + move_vals_to_write = {} + st_line_vals_to_write = {} + + if 'line_ids' in changed_fields: + liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines() + company_currency = st_line.journal_id.company_id.currency_id + journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency\ + else False + + if len(liquidity_lines) != 1: + raise UserError(_( + "The journal entry %s reached an invalid state regarding its related statement line.\n" + "To be consistent, the journal entry must always have exactly one journal item involving the " + "bank/cash account." + ) % st_line.move_id.display_name) + + st_line_vals_to_write.update({ + 'payment_ref': liquidity_lines.name, + 'partner_id': liquidity_lines.partner_id.id, + }) + + # Update 'amount' according to the liquidity line. + + if journal_currency: + st_line_vals_to_write.update({ + 'amount': liquidity_lines.amount_currency, + }) + else: + st_line_vals_to_write.update({ + 'amount': liquidity_lines.balance, + }) + + if len(suspense_lines) == 1: + + if journal_currency and suspense_lines.currency_id == journal_currency: + + # The suspense line is expressed in the journal's currency meaning the foreign currency + # set on the statement line is no longer needed. + + st_line_vals_to_write.update({ + 'amount_currency': 0.0, + 'foreign_currency_id': False, + }) + + elif not journal_currency and suspense_lines.currency_id == company_currency: + + # Don't set a specific foreign currency on the statement line. + + st_line_vals_to_write.update({ + 'amount_currency': 0.0, + 'foreign_currency_id': False, + }) + + else: + + # Update the statement line regarding the foreign currency of the suspense line. + + st_line_vals_to_write.update({ + 'amount_currency': -suspense_lines.amount_currency, + 'foreign_currency_id': suspense_lines.currency_id.id, + }) + + move_vals_to_write.update({ + 'partner_id': liquidity_lines.partner_id.id, + 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, + }) + + move.write(move._cleanup_write_orm_values(move, move_vals_to_write)) + st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write)) + + def _synchronize_to_moves(self, changed_fields): + """ Update the account.move regarding the modified account.bank.statement.line. + :param changed_fields: A list containing all modified fields on account.bank.statement.line. + """ + if self._context.get('skip_account_move_synchronization'): + return + + if not any(field_name in changed_fields for field_name in ( + 'payment_ref', 'amount', 'amount_currency', + 'foreign_currency_id', 'currency_id', 'partner_id', + )): + return + + for st_line in self.with_context(skip_account_move_synchronization=True): + liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() + journal = st_line.journal_id + company_currency = journal.company_id.currency_id + journal_currency = journal.currency_id if journal.currency_id != company_currency else False + + line_vals_list = st_line._prepare_move_line_default_vals() + line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])] + + if suspense_lines: + line_ids_commands.append((1, suspense_lines.id, line_vals_list[1])) + else: + line_ids_commands.append((0, 0, line_vals_list[1])) + + for line in other_lines: + line_ids_commands.append((2, line.id)) + + st_line_vals = { + 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, + 'line_ids': line_ids_commands, + } + if st_line.move_id.journal_id != journal: + st_line_vals['journal_id'] = journal.id + if st_line.move_id.partner_id != st_line.partner_id: + st_line_vals['partner_id'] = st_line.partner_id.id + st_line.move_id.write(st_line_vals) + + +# For optimization purpose, creating the reverse relation of m2o in _inherits saves +# a lot of SQL queries +class AccountMove(models.Model): + _name = "account.move" + _inherit = ['account.move'] + + statement_line_ids = fields.One2many('account.bank.statement.line', 'move_id', string='Statements') diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 12b8e52db44..e1c59385f0d 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -892,7 +892,7 @@ class AccountJournal(models.Model): :return: An account.bank.statement record or an empty recordset. ''' self.ensure_one() - last_statement_domain = (domain or []) + [('journal_id', '=', self.id)] + last_statement_domain = (domain or []) + [('journal_id', '=', self.id), ('statement_id', '!=', False)] last_st_line = self.env['account.bank.statement.line'].search(last_statement_domain, order='date desc, id desc', limit=1) return last_st_line.statement_id diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 3ad497a49d7..80797ed9edd 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from babel.dates import format_datetime, format_date from odoo import models, api, _, fields +from odoo.exceptions import UserError from odoo.osv import expression from odoo.release import version from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF @@ -125,7 +126,6 @@ class account_journal(models.Model): return {'x':short_name,'y': amount, 'name':name} self.ensure_one() - BankStatement = self.env['account.bank.statement'] data = [] today = datetime.today() last_month = today + timedelta(days=-30) @@ -278,11 +278,10 @@ class account_journal(models.Model): SELECT COUNT(st_line.id) FROM account_bank_statement_line st_line JOIN account_move st_line_move ON st_line_move.id = st_line.move_id - JOIN account_bank_statement st ON st_line.statement_id = st.id WHERE st_line_move.journal_id IN %s - AND st.state = 'posted' AND NOT st_line.is_reconciled AND st_line_move.to_check IS NOT TRUE + AND st_line_move.state = 'posted' ''', [tuple(self.ids)]) number_to_reconcile = self.env.cr.fetchone()[0] @@ -449,27 +448,7 @@ class account_journal(models.Model): } def create_cash_statement(self): - ctx = self._context.copy() - ctx.update({'journal_id': self.id, 'default_journal_id': self.id, 'default_journal_type': 'cash'}) - open_statements = self.env['account.bank.statement'].search([('journal_id', '=', self.id), ('state', '=', 'open')]) - action = { - 'name': _('Create cash statement'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'account.bank.statement', - 'context': ctx, - } - if len(open_statements) == 1: - action.update({ - 'view_mode': 'form', - 'res_id': open_statements.id, - }) - elif len(open_statements) > 1: - action.update({ - 'view_mode': 'tree,form', - 'domain': [('id', 'in', open_statements.ids)], - }) - return action + raise UserError(_('Please install Accounting for this feature')) def action_create_vendor_bill(self): """ This function is called by the "Import" button of Vendor Bills, diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index e8b678bce98..9947816b8b1 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -162,13 +162,6 @@ class AccountMove(models.Model): copy=False, check_company=True, ) - # used to open the linked bank statement from the edit button in a group by - # view, or via the smart button on journal entries. - statement_id = fields.Many2one( - related='statement_line_id.statement_id', - copy=False, - readonly=True, - ) # === Cash basis feature fields === # # used to keep track of the tax cash basis reconciliation. This is needed @@ -614,6 +607,8 @@ class AccountMove(models.Model): return self.payment_id.journal_id if self.statement_line_id and self.statement_line_id.journal_id: return self.statement_line_id.journal_id + if self.statement_line_ids.statement_id.journal_id: + return self.statement_line_ids.statement_id.journal_id[:1] if self.is_sale_document(include_receipts=True): journal_types = ['sale'] @@ -2403,7 +2398,7 @@ class AccountMove(models.Model): # EXTENDS account sequence.mixin self.ensure_one() is_payment = self.payment_id or self._context.get('is_payment') - if self.journal_id.type == 'sale': + if self.journal_id.type in ['sale', 'bank', 'cash']: starting_sequence = "%s/%04d/00000" % (self.journal_id.code, self.date.year) else: starting_sequence = "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month) @@ -3320,15 +3315,6 @@ class AccountMove(models.Model): 'views': [(False, 'form')], } - def open_bank_statement_view(self): - return { - 'type': 'ir.actions.act_window', - 'res_model': 'account.bank.statement', - 'view_mode': 'form', - 'res_id': self.statement_id.id, - 'views': [(False, 'form')], - } - def open_payment_view(self): return { 'type': 'ir.actions.act_window', diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index efc4828bb0c..17b9bd82e57 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -132,11 +132,10 @@ class AccountPayment(models.Model): compute='_compute_stat_buttons_from_reconciliation', help="Statements lines matched to this payment", ) - reconciled_statement_ids = fields.Many2many('account.bank.statement', string="Reconciled Statements", - compute='_compute_stat_buttons_from_reconciliation', - help="Statements matched to this payment") - reconciled_statements_count = fields.Integer(string="# Reconciled Statements", - compute="_compute_stat_buttons_from_reconciliation") + reconciled_statement_lines_count = fields.Integer( + string="# Reconciled Statement Lines", + compute="_compute_stat_buttons_from_reconciliation", + ) # == Display purpose fields == payment_method_code = fields.Char( @@ -563,8 +562,7 @@ class AccountPayment(models.Model): self.reconciled_bill_ids = False self.reconciled_bills_count = 0 self.reconciled_statement_line_ids = False - self.reconciled_statement_ids = False - self.reconciled_statements_count = 0 + self.reconciled_statement_lines_count = 0 return self.env['account.move'].flush_model() @@ -629,7 +627,7 @@ class AccountPayment(models.Model): WHERE account.id = payment.outstanding_account_id AND payment.id IN %(payment_ids)s AND line.id != counterpart_line.id - AND counterpart_line.statement_id IS NOT NULL + AND counterpart_line.statement_line_id IS NOT NULL GROUP BY payment.id ''', { 'payment_ids': tuple(stored_payments.ids) @@ -639,8 +637,7 @@ class AccountPayment(models.Model): for pay in self: statement_line_ids = query_res.get(pay.id, []) pay.reconciled_statement_line_ids = [Command.set(statement_line_ids)] - pay.reconciled_statement_ids = [Command.set(pay.reconciled_statement_line_ids.statement_id.ids)] - pay.reconciled_statements_count = len(statement_line_ids) + pay.reconciled_statement_lines_count = len(statement_line_ids) if len(pay.reconciled_invoice_ids.mapped('move_type')) == 1 and pay.reconciled_invoice_ids[0].move_type == 'out_refund': pay.reconciled_invoices_type = 'credit_note' else: @@ -981,27 +978,27 @@ class AccountPayment(models.Model): }) return action - def button_open_statements(self): + def button_open_statement_lines(self): ''' Redirect the user to the statement line(s) reconciled to this payment. :return: An action on account.move. ''' self.ensure_one() action = { - 'name': _("Matched Statements"), + 'name': _("Matched Transactions"), 'type': 'ir.actions.act_window', - 'res_model': 'account.bank.statement', + 'res_model': 'account.bank.statement.line', 'context': {'create': False}, } - if len(self.reconciled_statement_ids) == 1: + if len(self.reconciled_statement_lines_ids) == 1: action.update({ 'view_mode': 'form', - 'res_id': self.reconciled_statement_ids.id, + 'res_id': self.reconciled_statement_lines_ids.id, }) else: action.update({ 'view_mode': 'list,form', - 'domain': [('id', 'in', self.reconciled_statement_ids.ids)], + 'domain': [('id', 'in', self.reconciled_statement_lines_ids.ids)], }) return action diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index a07a9f6bce5..6e465d18996 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -408,7 +408,7 @@ class AccountChartTemplate(models.Model): the provided company (meaning hence that its chart of accounts cannot be changed anymore). """ - model_to_check = ['account.payment', 'account.bank.statement'] + model_to_check = ['account.payment', 'account.bank.statement.line'] for model in model_to_check: if self.env[model].sudo().search([('company_id', '=', company_id.id)], limit=1): return True diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 21d48c27a6a..13eaaaf53cb 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -286,22 +286,22 @@ class ResCompany(models.Model): :param unreconciled_statement_lines: The statement lines. :return: A dictionary representing a window action. """ - statements = unreconciled_statement_lines.statement_id + action = { - 'name': _("Unreconciled Statements"), + 'name': _("Unreconciled Transactions"), 'type': 'ir.actions.act_window', - 'res_model': 'account.bank.statement', + 'res_model': 'account.bank.statement.line', 'context': {'create': False}, } - if len(statements) == 1: + if len(unreconciled_statement_lines) == 1: action.update({ 'view_mode': 'form', - 'res_id': statements.id, + 'res_id': unreconciled_statement_lines.id, }) else: action.update({ 'view_mode': 'list,form', - 'domain': [('id', 'in', statements.ids)], + 'domain': [('id', 'in', unreconciled_statement_lines.ids)], }) return action diff --git a/addons/account/populate/account_bank_statement.py b/addons/account/populate/account_bank_statement.py index 654074fea8c..4015d86f466 100644 --- a/addons/account/populate/account_bank_statement.py +++ b/addons/account/populate/account_bank_statement.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """Classes defining the populate factory for Bank Statements and related models.""" -from odoo import models + +from odoo import models, Command from odoo.tools import populate from dateutil.relativedelta import relativedelta from functools import lru_cache -from collections import defaultdict import logging _logger = logging.getLogger(__name__) @@ -15,28 +15,42 @@ class AccountBankStatement(models.Model): """Populate factory part for account.bank.statements.""" _inherit = "account.bank.statement" - _populate_sizes = { - 'small': 10, - 'medium': 1000, - 'large': 20000, - } + _populate_dependencies = ['account.bank.statement.line'] - _populate_dependencies = ['account.journal', 'res.company'] + def _populate(self, size): + """ + Populate the bank statements with random lines. + :param size: + :return: + """ + rand = populate.Random('account_bank_statement+Populate') - def _populate_factories(self): - company_ids = self.env['res.company'].search([ - ('chart_template_id', '!=', False), - ('id', 'in', self.env.registry.populated_models['res.company']), - ]) - journal_ids = self.env['account.journal'].search([ - ('company_id', 'in', company_ids.ids), - ('type', 'in', ('cash', 'bank')), - ]).ids - return [ - ('journal_id', populate.iterate(journal_ids)), - ('name', populate.constant('statement_{counter}')), - ('date', populate.randdatetime(relative_before=relativedelta(years=-4))), - ] + read_group_res = self.env['account.bank.statement.line'].read_group( + [('statement_id', '=', False)], + ['ids:array_agg(id)'], + ['journal_id'], + ) + + bank_statement_vals_list = [] + for res in read_group_res: + available_ids = res['ids'] + nb_ids = len(available_ids) + while nb_ids > 0: + batch_size = min(rand.randint(1, 19), nb_ids) + nb_ids -= batch_size + + # 50% to create a statement. + statement_needed = bool(rand.randint(0, 1)) + if not statement_needed: + continue + + bank_statement_vals_list.append({ + 'name': f"statement_{len(bank_statement_vals_list) + 1}", + 'journal_id': res['journal_id'][0], + 'line_ids': [Command.set(res['ids'])], + }) + + return self.env['account.bank.statement'].create(bank_statement_vals_list) class AccountBankStatementLine(models.Model): @@ -50,7 +64,7 @@ class AccountBankStatementLine(models.Model): 'large': 200000, } - _populate_dependencies = ['account.bank.statement', 'res.partner'] + _populate_dependencies = ['account.journal', 'res.company', 'res.partner'] def _populate_factories(self): @lru_cache() @@ -74,61 +88,48 @@ class AccountBankStatementLine(models.Model): :param values (dict): the values already selected for the record. :return (int): an id of a partner accessible by the company of the statement. """ - company_id = self.env['account.bank.statement'].browse(values['statement_id']).company_id.id + company_id = self.env['account.journal'].browse(values['journal_id']).company_id.id partner = search_partner_ids(company_id) return random.choices(partner + [False], [1/len(partner)] * len(partner) + [1])[0] - def get_date(random, values, **kwargs): - """Get a date in the past. - - This date can but up to 31 days before the statement linked to this line. - :param random: seeded random number generator. - :param values (dict): the values already selected for the record. - :return (datetime.date): a date up to 31 days before the date of the statement. + def get_amount_currency(random, values, **kwargs): """ - statement_date = self.env['account.bank.statement'].browse(values['statement_id']).date - return statement_date + relativedelta(days=random.randint(-31, 0)) + Get a random amount currency between one tenth of amount and 10 times amount with the same sign + if foreign_currency_id is set - def get_amount(random, **kwargs): - """Get a random amount between -1000 and 1000. - - It is impossible to get a null amount. Because it would not be a valid statement line. :param random: seeded random number generator. - :return (float): a number between -1000 and 1000. + :return (float): a number between amount / 10 and amount * 10. """ - return random.uniform(-1000, 1000) or 1 + return random.uniform(0.1 * values['amount'], 10 * values['amount']) if values['foreign_currency_id'] else 0 def get_currency(random, values, **kwargs): - """Get a randome currency. + """Get a random currency. - The currency has to be empty if it is the same as the currency of the statement's journal's. + The currency has to be empty if it is the same as the currency of the line's journal's. :param random: seeded random number generator. :param values (dict): the values already selected for the record. :return (int, bool): the id of an active currency or False if it is the same currency as - the statement's journal's currency. + the lines's journal's currency. """ - journal = self.env['account.bank.statement'].browse(values['statement_id']).journal_id + journal = self.env['account.journal'].browse(values['journal_id']) currency = random.choice(self.env['res.currency'].search([('active', '=', True)]).ids) return currency if currency != (journal.currency_id or journal.company_id.currency_id).id else False - # Because we are accessing related fields of bank statements, a prefetch can improve the performances. - self = self.with_prefetch(self.env.registry.populated_models['account.bank.statement']) - return [ - ('statement_id', populate.randomize(self.env.registry.populated_models['account.bank.statement'])), - ('partner_id', populate.compute(get_partner)), - ('payment_ref', populate.constant('statement_{values[statement_id]}_{counter}')), - ('date', populate.compute(get_date)), - ('amount', populate.compute(get_amount)), - ('currency_id', populate.compute(get_currency)), - ] + company_ids = self.env['res.company'].search([ + ('chart_template_id', '!=', False), + ('id', 'in', self.env.registry.populated_models['res.company']), + ]) - def _populate(self, size): - records = super()._populate(size) - _logger.info('Posting Bank Statements') - statements = records.statement_id.sorted(lambda r: (r.date, r.name, r.id)) - previous = defaultdict(int) - for statement in statements: - statement.balance_start = previous[statement.journal_id] - previous[statement.journal_id] = statement.balance_end_real = statement.balance_start + statement.total_entry_encoding - statements.button_post() - return records + journal_ids = self.env['account.journal'].search([ + ('company_id', 'in', company_ids.ids), + ('type', 'in', ('cash', 'bank')), + ]).ids + return [ + ('journal_id', populate.iterate(journal_ids)), + ('partner_id', populate.compute(get_partner)), + ('date', populate.randdatetime(relative_before=relativedelta(years=-4))), + ('payment_ref', populate.constant('transaction_{values[date]}_{counter}')), + ('amount', populate.randint(-1000, 1000)), + ('foreign_currency_id', populate.compute(get_currency)), + ('amount_currency', populate.compute(get_amount_currency)), + ] diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index a64c8a3f55f..25ac5427a51 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -35,8 +35,6 @@ access_account_fiscal_position_account,account.fiscal.position all,model_account access_product_product_account_user,product.product.account.user,product.model_product_product,group_account_readonly,1,0,0,0 access_product_product_account_manager,product.product.account.manager,product.model_product_product,account.group_account_manager,1,1,1,1 -access_account_cashbox,account.bank.statement.cashbox,model_account_bank_statement_cashbox,account.group_account_user,1,1,1,1 -access_account_cashbox_line,account.bank.statement.cashbox.line,model_account_cashbox_line,account.group_account_user,1,1,1,1 access_account_bank_statement_group_readonly,account.bank.statement.group.invoice,model_account_bank_statement,account.group_account_readonly,1,0,0,0 access_account_bank_statement_group_invoice,account.bank.statement.group.invoice,model_account_bank_statement,account.group_account_invoice,1,0,0,0 access_account_bank_statement_line_group_readonly,account.bank.statement.line.group.invoice,model_account_bank_statement_line,account.group_account_readonly,1,0,0,0 @@ -122,12 +120,10 @@ access_account_payment_readonly,account.payment,model_account_payment,account.gr access_account_payment,account.payment,model_account_payment,account.group_account_invoice,1,1,1,1 access_account_payment_register,access.account.payment.register,model_account_payment_register,account.group_account_invoice,1,1,1,0 -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_automatic_entry_wizard,access.account.automatic.entry.wizard,model_account_automatic_entry_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_manager,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 access_account_financial_year_op,access.account.financial.year.op,model_account_financial_year_op,account.group_account_manager,1,1,1,0 access_account_setup_bank_manual_config,access.account.setup.bank.manual.config,model_account_setup_bank_manual_config,account.group_account_manager,1,1,1,0 diff --git a/addons/account/tests/test_account_bank_statement.py b/addons/account/tests/test_account_bank_statement.py index 05a8a9f334e..8b23c028d00 100644 --- a/addons/account/tests/test_account_bank_statement.py +++ b/addons/account/tests/test_account_bank_statement.py @@ -6,7 +6,8 @@ from odoo.exceptions import ValidationError, UserError from odoo import fields, Command -class TestAccountBankStatementCommon(AccountTestInvoicingCommon): +@tagged('post_install', '-at_install') +class TestAccountBankStatementLine(AccountTestInvoicingCommon): @classmethod def setUpClass(cls, chart_template_ref=None): @@ -34,319 +35,15 @@ class TestAccountBankStatementCommon(AccountTestInvoicingCommon): cls.currency_3 = cls.currency_data_2['currency'] cls.currency_4 = cls.currency_data_3['currency'] - def assertBankStatementLine(self, statement_line, expected_statement_line_vals, expected_move_line_vals): - self.assertRecordValues(statement_line, [expected_statement_line_vals]) - self.assertRecordValues(statement_line.line_ids.sorted('balance'), expected_move_line_vals) - - -@tagged('post_install', '-at_install') -class TestAccountBankStatement(TestAccountBankStatementCommon): - - # ------------------------------------------------------------------------- - # TESTS about the statement model. - # ------------------------------------------------------------------------- - - def test_starting_ending_balance_chaining(self): - # Create first statement on 2019-01-02. - bnk1 = self.env['account.bank.statement'].create({ - 'name': 'BNK1', - 'date': '2019-01-02', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], - }) - self.assertRecordValues(bnk1, [{ - 'balance_start': 0.0, - 'balance_end_real': 100.0, - 'balance_end': 100.0, - 'previous_statement_id': False, - }]) - - # Create a new statement after that one. - bnk2 = self.env['account.bank.statement'].create({ - 'name': 'BNK2', - 'date': '2019-01-10', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 50.0})], - }) - self.assertRecordValues(bnk2, [{ - 'balance_start': 100.0, - 'balance_end_real': 150.0, - 'balance_end': 150.0, - 'previous_statement_id': bnk1.id, - }]) - - # Create new statement with given ending balance. - bnk3 = self.env['account.bank.statement'].create({ - 'name': 'BNK3', - 'date': '2019-01-15', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 25.0})], - 'balance_end_real': 200.0, - }) - self.assertRecordValues(bnk3, [{ - 'balance_start': 150.0, - 'balance_end_real': 200.0, - 'balance_end': 175.0, - 'previous_statement_id': bnk2.id, - }]) - - # Create new statement with a date right after BNK1. - bnk4 = self.env['account.bank.statement'].create({ - 'name': 'BNK4', - 'date': '2019-01-03', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], - }) - self.assertRecordValues(bnk4, [{ - 'balance_start': 100.0, - 'balance_end_real': 200.0, - 'balance_end': 200.0, - 'previous_statement_id': bnk1.id, - }]) - - # BNK2/BNK3 should have changed their previous statements. - self.assertRecordValues(bnk2, [{ - 'balance_start': 200.0, - 'balance_end_real': 250.0, - 'balance_end': 250.0, - 'previous_statement_id': bnk4.id, - }]) - self.assertRecordValues(bnk3, [{ - 'balance_start': 250.0, - 'balance_end_real': 200.0, - 'balance_end': 275.0, - 'previous_statement_id': bnk2.id, - }]) - - # Correct the ending balance of BNK3. - bnk3.balance_end_real = 275 - - # Change date of BNK4 to be the last. - bnk4.date = '2019-01-20' - self.assertRecordValues(bnk1, [{ - 'balance_start': 0.0, - 'balance_end_real': 100.0, - 'balance_end': 100.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk2, [{ - 'balance_start': 100.0, - 'balance_end_real': 150.0, - 'balance_end': 150.0, - 'previous_statement_id': bnk1.id, - }]) - self.assertRecordValues(bnk3, [{ - 'balance_start': 150.0, - 'balance_end_real': 175.0, - 'balance_end': 175.0, - 'previous_statement_id': bnk2.id, - }]) - self.assertRecordValues(bnk4, [{ - 'balance_start': 175.0, - 'balance_end_real': 200.0, - 'balance_end': 275.0, - 'previous_statement_id': bnk3.id, - }]) - - # Correct the ending balance of BNK4. - bnk4.balance_end_real = 275 - - # Move BNK3 to first position. - bnk3.date = '2019-01-01' - self.assertRecordValues(bnk3, [{ - 'balance_start': 0.0, - 'balance_end_real': 25.0, - 'balance_end': 25.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk1, [{ - 'balance_start': 25.0, - 'balance_end_real': 125.0, - 'balance_end': 125.0, - 'previous_statement_id': bnk3.id, - }]) - self.assertRecordValues(bnk2, [{ - 'balance_start': 125.0, - 'balance_end_real': 175.0, - 'balance_end': 175.0, - 'previous_statement_id': bnk1.id, - }]) - self.assertRecordValues(bnk4, [{ - 'balance_start': 175.0, - 'balance_end_real': 275.0, - 'balance_end': 275.0, - 'previous_statement_id': bnk2.id, - }]) - - # Move BNK1 to the third position. - bnk1.date = '2019-01-11' - self.assertRecordValues(bnk3, [{ - 'balance_start': 0.0, - 'balance_end_real': 25.0, - 'balance_end': 25.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk2, [{ - 'balance_start': 25.0, - 'balance_end_real': 75.0, - 'balance_end': 75.0, - 'previous_statement_id': bnk3.id, - }]) - self.assertRecordValues(bnk1, [{ - 'balance_start': 75.0, - 'balance_end_real': 175.0, - 'balance_end': 175.0, - 'previous_statement_id': bnk2.id, - }]) - self.assertRecordValues(bnk4, [{ - 'balance_start': 175.0, - 'balance_end_real': 275.0, - 'balance_end': 275.0, - 'previous_statement_id': bnk1.id, - }]) - - # Delete BNK3 and BNK1. - (bnk3 + bnk1).unlink() - self.assertRecordValues(bnk2, [{ - 'balance_start': 0.0, - 'balance_end_real': 50.0, - 'balance_end': 50.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk4, [{ - 'balance_start': 50.0, - 'balance_end_real': 275.0, - 'balance_end': 150.0, - 'previous_statement_id': bnk2.id, - }]) - - def test_statements_different_journal(self): - # Create statements in bank journal. - bnk1_1 = self.env['account.bank.statement'].create({ - 'name': 'BNK1_1', - 'date': '2019-01-01', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], - 'balance_end_real': 100.0, - }) - bnk1_2 = self.env['account.bank.statement'].create({ - 'name': 'BNK1_2', - 'date': '2019-01-10', - 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 50.0})], - }) - - # Create statements in cash journal. - bnk2_1 = self.env['account.bank.statement'].create({ - 'name': 'BNK2_1', - 'date': '2019-01-02', - 'journal_id': self.company_data['default_journal_cash'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 20.0})], - 'balance_end_real': 20.0, - }) - bnk2_2 = self.env['account.bank.statement'].create({ - 'name': 'BNK2_2', - 'date': '2019-01-12', - 'journal_id': self.company_data['default_journal_cash'].id, - 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 10.0})], - }) - self.assertRecordValues(bnk1_1, [{ - 'balance_start': 0.0, - 'balance_end_real': 100.0, - 'balance_end': 100.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk1_2, [{ - 'balance_start': 100.0, - 'balance_end_real': 150.0, - 'balance_end': 150.0, - 'previous_statement_id': bnk1_1.id, - }]) - self.assertRecordValues(bnk2_1, [{ - 'balance_start': 0.0, - 'balance_end_real': 20.0, - 'balance_end': 20.0, - 'previous_statement_id': False, - }]) - self.assertRecordValues(bnk2_2, [{ - 'balance_start': 20.0, - 'balance_end_real': 0.0, - 'balance_end': 30.0, - 'previous_statement_id': bnk2_1.id, - }]) - - def test_cash_statement_with_difference(self): - ''' A cash statement always creates an additional line to store the cash difference towards the ending balance. - ''' - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': self.company_data['default_journal_cash'].id, - 'balance_end_real': 100.0, - }) - - statement.button_post() - - self.assertRecordValues(statement.line_ids, [{ - 'amount': 100.0, - 'is_reconciled': True, - }]) - - def test_bank_statement_with_difference(self): - """ Test that a bank statement with difference could be posted but not validated. """ - bank_statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': self.bank_journal_1.id, - 'balance_start': 10.0, - 'balance_end_real': 100.0, - 'balance_end': 10.0, - 'line_ids': [Command.create({ - 'date': fields.Date.to_date('2019-01-01'), - 'payment_ref': 'transaction_abc', - 'amount': 10, - 'partner_id': self.partner_a.id, - })] - }) - bank_statement.button_post() - # Check if the bank statement is well posted (No check on the balance as the balance could be edited after). - self.assertEqual(bank_statement.state, 'posted') - - # Check that the bank statement couldn't be validated with balance_end != balance_end_real - with self.assertRaises(UserError): - bank_statement.button_validate() - - # Check if we can validate the bank statement if balance_end == balance_end_real - bank_statement.balance_end_real = bank_statement.balance_end - - # Reconcile without the bank reconciliation widget since the widget is in enterprise. - statement_line = bank_statement.line_ids - st_liquidity_lines, st_suspense_lines, _st_other_lines = statement_line \ - .with_context(skip_account_move_synchronization=True) \ - ._seek_for_lines() - st_suspense_lines.account_id = st_liquidity_lines.account_id - (st_suspense_lines + st_liquidity_lines).reconcile() - - bank_statement.button_validate() - self.assertEqual(bank_statement.state, 'confirm') - -@tagged('post_install', '-at_install') -class TestAccountBankStatementLine(TestAccountBankStatementCommon): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - cls.statement = cls.env['account.bank.statement'].create({ 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': cls.bank_journal_1.id, 'line_ids': [ (0, 0, { 'date': '2019-01-01', 'payment_ref': 'line_1', 'partner_id': cls.partner_a.id, 'foreign_currency_id': cls.currency_2.id, + 'journal_id': cls.bank_journal_1.id, 'amount': 1250.0, 'amount_currency': 2500.0, }), @@ -386,6 +83,36 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): 'amount_currency': -2500.0, } + def assertBankStatementLine(self, statement_line, expected_statement_line_vals, expected_move_line_vals): + self.assertRecordValues(statement_line, [expected_statement_line_vals]) + self.assertRecordValues(statement_line.line_ids.sorted('balance'), expected_move_line_vals) + + def create_bank_transaction(self, amount, date, amount_currency=None, currency=None, statement=None, + partner=None, journal=None, sequence=0): + values = { + 'payment_ref': str(amount), + 'amount': amount, + 'date': date, + 'partner_id': partner and partner.id, + 'sequence': sequence, + } + if amount_currency: + values['amount_currency'] = amount_currency + values['foreign_currency_id'] = currency.id + if statement and journal and statement.journal_id != journal: + raise (ValidationError("The statement and the journal are contradictory")) + if statement: + values['journal_id'] = statement.journal_id.id + values['statement_id'] = statement.id + if journal: + values['journal_id'] = journal.id + if not values.get('journal_id'): + values['journal_id'] = (self.company_data_2['default_journal_bank'] + if self.env.company == self.company_data_2['company'] + else self.company_data['default_journal_bank'] + ).id + return self.env['account.bank.statement.line'].create(values) + # ------------------------------------------------------------------------- # TESTS about the statement line model. # ------------------------------------------------------------------------- @@ -408,22 +135,15 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): if journal_currency: journal.currency_id = journal_currency.id - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', + statement_line = self.env['account.bank.statement.line'].create({ 'date': '2019-01-01', 'journal_id': journal.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'foreign_currency_id': foreign_currency and foreign_currency.id, - 'amount': amount, - 'amount_currency': amount_currency, - }), - ], + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': foreign_currency and foreign_currency.id, + 'amount': amount, + 'amount_currency': amount_currency, }) - statement_line = statement.line_ids # ==== Test the statement line amounts are correct ==== # If there is a bug in the compute/inverse methods, the amount/amount_currency could be @@ -532,6 +252,7 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_edition_journal_curr_2_statement_curr_3(self): self._test_edition_customer_and_supplier_flows( + # pylint: disable=bad-whitespace 80.0, 120.0, self.currency_2, self.currency_3, {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, @@ -540,6 +261,7 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_edition_journal_curr_2_statement_curr_1(self): self._test_edition_customer_and_supplier_flows( + # pylint: disable=bad-whitespace 120.0, 80.0, self.currency_2, self.currency_1, {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, @@ -548,7 +270,7 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_edition_journal_curr_1_statement_curr_2(self): self._test_edition_customer_and_supplier_flows( - # pylint: disable=C0326 + # pylint: disable=bad-whitespace 80.0, 120.0, self.currency_1, self.currency_2, {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, @@ -557,6 +279,7 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_edition_journal_curr_2_statement_false(self): self._test_edition_customer_and_supplier_flows( + # pylint: disable=bad-whitespace 80.0, 0.0, self.currency_2, False, {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, @@ -565,6 +288,7 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_edition_journal_curr_1_statement_false(self): self._test_edition_customer_and_supplier_flows( + # pylint: disable=bad-whitespace 80.0, 0.0, self.currency_1, False, {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, @@ -574,24 +298,18 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_zero_amount_journal_curr_1_statement_curr_2(self): self.bank_journal_2.currency_id = self.currency_1 - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': self.bank_journal_2.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'foreign_currency_id': self.currency_2.id, - 'amount': 0.0, - 'amount_currency': 10.0, - }), - ], + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_2.id, + 'amount': 0.0, + 'amount_currency': 10.0, }) - self.assertRecordValues(statement.line_ids.move_id.line_ids, [ - # pylint: disable=C0326 + self.assertRecordValues(statement_line.move_id.line_ids, [ + # pylint: disable=bad-whitespace {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_1.id}, {'debit': 0.0, 'credit': 0.0, 'amount_currency': -10.0, 'currency_id': self.currency_2.id}, ]) @@ -599,23 +317,18 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_zero_amount_journal_curr_2_statement_curr_1(self): self.bank_journal_2.currency_id = self.currency_2 - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': self.bank_journal_2.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'foreign_currency_id': self.currency_1.id, - 'amount': 0.0, - 'amount_currency': 10.0, - }), - ], + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_1.id, + 'amount': 0.0, + 'amount_currency': 10.0, }) - self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + self.assertRecordValues(statement_line.move_id.line_ids, [ + # pylint: disable=bad-whitespace {'debit': 10.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, {'debit': 0.0, 'credit': 10.0, 'amount_currency': -10.0, 'currency_id': self.currency_1.id}, ]) @@ -623,41 +336,29 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): def test_zero_amount_journal_curr_2_statement_curr_3(self): self.bank_journal_2.currency_id = self.currency_2 - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': self.bank_journal_2.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'foreign_currency_id': self.currency_3.id, - 'amount': 0.0, - 'amount_currency': 10.0, - }), - ], + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_3.id, + 'amount': 0.0, + 'amount_currency': 10.0, }) - self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + self.assertRecordValues(statement_line.move_id.line_ids, [ + # pylint: disable=bad-whitespace {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, {'debit': 0.0, 'credit': 0.0, 'amount_currency': -10.0, 'currency_id': self.currency_3.id}, ]) def test_constraints(self): - def assertStatementLineConstraint(statement_vals, statement_line_vals): + def assertStatementLineConstraint(statement_line_vals): with self.assertRaises(Exception), self.cr.savepoint(): - self.env['account.bank.statement'].create({ - **statement_vals, - 'line_ids': [(0, 0, statement_line_vals)], - }) + self.env['account.bank.statement.line'].create(statement_line_vals) - statement_vals = { - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': self.bank_journal_2.id, - } statement_line_vals = { + 'journal_id': self.bank_journal_2.id, 'date': '2019-01-01', 'payment_ref': 'line_1', 'partner_id': self.partner_a.id, @@ -669,30 +370,26 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): # ==== Test constraints at creation ==== # Foreign currency must not be the same as the journal one. - assertStatementLineConstraint(statement_vals, { + assertStatementLineConstraint({ **statement_line_vals, 'foreign_currency_id': self.currency_1.id, }) # Can't have a stand alone amount in foreign currency without foreign currency set. - assertStatementLineConstraint(statement_vals, { + assertStatementLineConstraint({ **statement_line_vals, 'amount_currency': 10.0, }) # Can't have a foreign currency set without amount in foreign currency. - assertStatementLineConstraint(statement_vals, { + assertStatementLineConstraint({ **statement_line_vals, 'foreign_currency_id': self.currency_2.id, }) # ==== Test constraints at edition ==== - statement = self.env['account.bank.statement'].create({ - **statement_vals, - 'line_ids': [(0, 0, statement_line_vals)], - }) - st_line = statement.line_ids + st_line = self.env['account.bank.statement.line'].create(statement_line_vals) # You can't messed up the journal entry by adding another liquidity line. addition_lines_to_create = [ @@ -717,24 +414,21 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): with self.assertRaises(UserError), self.cr.savepoint(): st_line.line_ids.create(addition_lines_to_create) - # You can't set the journal entry in an unconsistent state. - with self.assertRaises(UserError), self.cr.savepoint(): - st_line.move_id.action_post() - def test_statement_line_move_onchange_1(self): ''' Test the consistency between the account.bank.statement.line and the generated account.move.lines using the form view emulator. ''' # Check the initial state of the statement line. - self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) + self.assertBankStatementLine(self.statement_line, self.expected_st_line, + [self.expected_counterpart_line, self.expected_bank_line]) # Inverse the amount + change them. - with Form(self.statement) as statement_form: - with statement_form.line_ids.edit(0) as st_line_form: - st_line_form.amount = -2000.0 - st_line_form.amount_currency = -4000.0 - st_line_form.foreign_currency_id = self.currency_3 + self.statement_line.write({ + 'amount': -2000.0, + 'amount_currency': -4000.0, + 'foreign_currency_id': self.currency_3.id, + }) self.assertBankStatementLine(self.statement_line, { **self.expected_st_line, @@ -759,10 +453,10 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): ]) # Check changing the label and the partner. - with Form(self.statement) as statement_form: - with statement_form.line_ids.edit(0) as st_line_form: - st_line_form.payment_ref = 'line_1 (bis)' - st_line_form.partner_id = self.partner_b + self.statement_line.write({ + 'payment_ref': 'line_1 (bis)', + 'partner_id': self.partner_b.id, + }) self.assertBankStatementLine(self.statement_line, { **self.expected_st_line, @@ -802,23 +496,17 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): journal = self.bank_journal_1.copy() journal.currency_id = journal_currency - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': journal.id, - 'line_ids': [ - Command.create({ - 'date': '2019-01-01', - 'payment_ref': 'test_prepare_counterpart_amounts_using_st_line_rate', - 'foreign_currency_id': foreign_currency.id if foreign_currency != journal_currency else None, - 'amount': amount, - 'amount_currency': amount_currency if foreign_currency != journal_currency else 0.0, - }), - ], + 'date': '2019-01-01', + 'payment_ref': 'test_prepare_counterpart_amounts_using_st_line_rate', + 'foreign_currency_id': foreign_currency.id if foreign_currency != journal_currency else None, + 'amount': amount, + 'amount_currency': amount_currency if foreign_currency != journal_currency else 0.0, }) - statement_line = statement.line_ids - res = statement_line._prepare_counterpart_amounts_using_st_line_rate(aml_currency, -aml_balance, -aml_amount_currency) + res = statement_line._prepare_counterpart_amounts_using_st_line_rate(aml_currency, -aml_balance, + -aml_amount_currency) self.assertAlmostEqual(res['amount_currency'], expected_amount_currency) self.assertAlmostEqual(res['balance'], expected_balance) @@ -847,13 +535,12 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): statement = self.env['account.bank.statement'].with_context(skip_check_amounts_currencies=True).create({ 'name': 'test_statement', - 'date': '2017-01-01', - 'journal_id': self.bank_journal_2.id, 'line_ids': [ (0, 0, { 'date': '2019-01-01', 'payment_ref': "Happy new year", 'amount': 0.0, + 'journal_id': self.bank_journal_2.id, }), ], }) @@ -861,29 +548,519 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): self.assertRecordValues(statement_line, [{'is_reconciled': True, 'amount_residual': 0.0}]) - def test_statement_line_note_onchange_partner(self): - """ - Check if narration field stays as it is when changing the partner - in reconciliation widget. - """ - bank_stmt = self.env['account.bank.statement'].create({ - 'company_id': self.env.company.id, - 'journal_id': self.bank_journal_1.id, - 'name': 'test', + def test_statement_valid_complete_1(self): + self.env.user.company_id = self.company_data_2['company'] + + # create a valid and complete statement as the first lines (no statement before) + line1 = self.create_bank_transaction(1, '2020-01-10') + line2 = self.create_bank_transaction(2, '2020-01-11') + statement1 = self.env['account.bank.statement'].create({ + 'line_ids': [Command.set((line1 + line2).ids)], + 'balance_start': 0, + 'balance_end_real': 3, }) - - bank_stmt_line = self.env['account.bank.statement.line'].create({ - 'payment_ref': 'testLine', - 'statement_id': bank_stmt.id, - 'narration': 'This is a note', - 'amount': 100, - }) - - bank_stmt_line.partner_id = self.partner_b - - self.assertRecordValues(bank_stmt_line, [{ - 'payment_ref': 'testLine', - 'statement_id': bank_stmt.id, - 'narration': '
This is a note
', - 'amount': 100, + self.assertRecordValues(statement1, [{ + 'is_complete': True, + 'is_valid': True, }]) + # remove the first line, so not complete but it is still valid because there is no statement before + line1.statement_id = False + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': False, + 'is_valid': True, + }]) + # create a new line in the statement to make it complete again. Starting value does not match the last line + # but it is still valid because the previous line has no statement + line3 = self.create_bank_transaction(1, '2020-01-12', statement=statement1) + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': True, + 'is_valid': True, + }]) + # add a statement to the first line, statement1 is still complete but not valid because balance start + # does not match the previous statement + statement2 = self.env['account.bank.statement'].create({ + 'line_ids': [Command.set(line1.ids)], + 'balance_end_real': 1, + }) + (statement1 + statement2).invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement2, [{ + 'is_complete': True, + 'is_valid': False, + }, { + 'is_complete': True, + 'is_valid': True, # first statement + }]) + # Fix the statement balance start, so the statement is not complete (end - start = 2, vs sum lines = 3) + # but it is matched with the previous statements balance end + statement1.balance_start = 1 + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': False, + 'is_valid': True, + }]) + # change the prev statement so the end balance does not match the start balance of statement 1 + statement2.balance_end_real = 10 + statement2.flush_recordset(['balance_end_real']) + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': False, + 'is_valid': False, + }]) + # make the statement valid again + statement1.balance_start = 10 + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': False, + 'is_valid': True, + }]) + # and complete again by adding a new transaction to it + line4 = self.create_bank_transaction(-10, '2020-01-13', statement=statement1) + (statement1 + statement2).invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement2, [{ + 'is_complete': True, + 'is_valid': True, + 'date': fields.Date.from_string('2020-01-13'), + }, { + 'is_complete': False, + 'is_valid': True, + 'date': fields.Date.from_string('2020-01-10'), + }]) + # check point + self.assertRecordValues(line1 + line2 + line3 + line4, [ + {'date': fields.Date.from_string('2020-01-10'), 'statement_id': statement2.id}, + {'date': fields.Date.from_string('2020-01-11'), 'statement_id': statement1.id}, + {'date': fields.Date.from_string('2020-01-12'), 'statement_id': statement1.id}, + {'date': fields.Date.from_string('2020-01-13'), 'statement_id': statement1.id}, + ]) + + # changing statement 2 balance makes statement 1 valid, + # but making statement 1 the first statement should make it valid again + statement2.balance_end_real = 100 + statement2.flush_recordset(['balance_end_real']) + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_valid': False, + }]) + line1.statement_id = False + line1.flush_model() + statement2.flush_model() + statement1.invalidate_model(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_valid': True, + }]) + + # having a gap in the statement shouldn't make it invalid + line3.statement_id = False + statement1.flush_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_valid': True, + }]) + + # Change the statement on one of the lines of statement 1 + statement3 = self.env['account.bank.statement'].create({ + 'line_ids': [Command.set(line4.ids)], + 'balance_start': -5, + }) + (statement1 + statement3).flush_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement3, [{ + 'is_valid': True, + }, { + 'is_valid': False, # balance does not match with statement1 + }]) + + # changing statement1 end_balance should change the validity of statement3 + statement1.balance_end_real = -5 + statement1.flush_recordset(['balance_end_real']) + (statement1 + statement3).invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement3, [{ + 'is_valid': True, + }, { + 'is_valid': True, # balance start matches previous end, despite the gap + }]) + # check point + self.assertRecordValues(line1 + line2 + line3 + line4, [ + {'date': fields.Date.from_string('2020-01-10'), 'statement_id': False}, + {'date': fields.Date.from_string('2020-01-11'), 'statement_id': statement1.id}, + {'date': fields.Date.from_string('2020-01-12'), 'statement_id': False}, + {'date': fields.Date.from_string('2020-01-13'), 'statement_id': statement3.id}, + ]) + self.assertRecordValues(statement1 + statement2 + statement3, [ + {'is_valid': True, 'balance_start': 10, 'balance_end_real': -5, + 'date': fields.Date.from_string('2020-01-11')}, + {'is_valid': True, 'balance_start': False, 'balance_end_real': 100, 'date': False}, + {'is_valid': True, 'balance_start': -5, 'balance_end_real': False, + 'date': fields.Date.from_string('2020-01-13')}, + ]) + + # adding a statement to the first line should make statement1 invalid + line1.statement_id = statement2 + statement2.flush_model() + (statement1 + statement2).invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement2, [{'is_valid': False}, {'is_valid': True}]) + + # moving statement2 the line between statement1 and statement3 should make statement1 valid again + # and statement3 invalid + statement2.line_ids = line3 + statement2.flush_model() + (statement1 + statement2 + statement3).invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1 + statement2 + statement3, [ + {'is_valid': True}, {'is_valid': False}, {'is_valid': False}, + ]) + + def test_statement_line_ordering(self): + self.env.user.company_id = self.company_data_2['company'] + + # the line numbers are chosen based on the order of the lines in the list view + line7 = self.create_bank_transaction(7, '2020-01-10', sequence=1) + line8 = self.create_bank_transaction(8, '2020-01-10', sequence=2) + line2 = self.create_bank_transaction(2, '2020-01-13') + _line6 = self.create_bank_transaction(6, '2020-01-11') + line5 = self.create_bank_transaction(5, '2020-01-12', sequence=3) + line4 = self.create_bank_transaction(4, '2020-01-12', sequence=2) + _line1 = self.create_bank_transaction(1, '2020-01-13') + line3 = self.create_bank_transaction(3, '2020-01-12', sequence=1) + + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': False}, + {'amount': 3, 'running_balance': 33, 'statement_id': False}, + {'amount': 4, 'running_balance': 30, 'statement_id': False}, + {'amount': 5, 'running_balance': 26, 'statement_id': False}, + {'amount': 6, 'running_balance': 21, 'statement_id': False}, + {'amount': 7, 'running_balance': 15, 'statement_id': False}, + {'amount': 8, 'running_balance': 8, 'statement_id': False}, + ], + ) + + # Same but with a subset of lines to ensure the balance is not only computed based on selected records. + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([ + ('company_id', '=', self.env.company.id), + ('amount', '>=', 3), + ('amount', '<=', 6), + ]), + [ + {'amount': 3, 'running_balance': 33}, + {'amount': 4, 'running_balance': 30}, + {'amount': 5, 'running_balance': 26}, + {'amount': 6, 'running_balance': 21}, + ], + ) + + # Put line2 -> line4 inside a statement with a wrong balance_end_real. + (line2 + line3 + line4).statement_id = statement1 = \ + self.env['account.bank.statement'].create({'balance_end_real': 9}) + self.assertRecordValues(statement1, [{ + 'is_complete': True, + }]) + + statement1.invalidate_recordset(['is_valid']) + statement1.balance_start = 26 + self.assertRecordValues(statement1, [{ + 'is_complete': False, + }]) + + statement1.invalidate_recordset(['is_valid']) + statement1.balance_end_real = 35 + self.assertRecordValues(statement1, [{ + 'is_complete': True, + }]) + # line3, line4 and line5 have the same date. Move line5 at the first place using the sequence. + line5.sequence = -1 + statement1.invalidate_recordset(['is_valid']) + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues(statement1, [{ + 'is_complete': True, + }]) + + statement1.balance_start = 21 + statement1.invalidate_recordset(['is_valid']) + self.assertRecordValues(statement1, [{ + 'is_complete': False, + }]) + + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': statement1.id}, + {'amount': 5, 'running_balance': 33, 'statement_id': False}, + {'amount': 3, 'running_balance': 28, 'statement_id': statement1.id}, + {'amount': 4, 'running_balance': 25, 'statement_id': statement1.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': False}, + {'amount': 7, 'running_balance': 15, 'statement_id': False}, + {'amount': 8, 'running_balance': 8, 'statement_id': False}, + ], + ) + + line8.amount = 18 + + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': statement1.id}, + {'amount': 5, 'running_balance': 33, 'statement_id': False}, + {'amount': 3, 'running_balance': 28, 'statement_id': statement1.id}, + {'amount': 4, 'running_balance': 25, 'statement_id': statement1.id}, + {'amount': 6, 'running_balance': 31, 'statement_id': False}, + {'amount': 7, 'running_balance': 25, 'statement_id': False}, + {'amount': 18, 'running_balance': 18, 'statement_id': False}, + ], + ) + line5.amount = 15 + + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 46, 'statement_id': False}, + {'amount': 2, 'running_balance': 45, 'statement_id': statement1.id}, + {'amount': 15, 'running_balance': 43, 'statement_id': False}, + {'amount': 3, 'running_balance': 28, 'statement_id': statement1.id}, + {'amount': 4, 'running_balance': 25, 'statement_id': statement1.id}, + {'amount': 6, 'running_balance': 31, 'statement_id': False}, + {'amount': 7, 'running_balance': 25, 'statement_id': False}, + {'amount': 18, 'running_balance': 18, 'statement_id': False}, + ], + ) + + line7.unlink() + + self.env['account.bank.statement.line'].invalidate_model(fnames=['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 46, 'statement_id': False}, + {'amount': 2, 'running_balance': 45, 'statement_id': statement1.id}, + {'amount': 15, 'running_balance': 43, 'statement_id': False}, + {'amount': 3, 'running_balance': 28, 'statement_id': statement1.id}, + {'amount': 4, 'running_balance': 25, 'statement_id': statement1.id}, + {'amount': 6, 'running_balance': 24, 'statement_id': False}, + {'amount': 18, 'running_balance': 18, 'statement_id': False}, + ], + ) + + def test_statement_split(self): + self.env.user.company_id = self.company_data_2['company'] + + # the line numbers are chosen based on the order of the lines in the list view + line7 = self.create_bank_transaction(7, '2020-01-10', sequence=1) + line8 = self.create_bank_transaction(8, '2020-01-10', sequence=2) + line2 = self.create_bank_transaction(2, '2020-01-13') + line6 = self.create_bank_transaction(6, '2020-01-11') + _line5 = self.create_bank_transaction(5, '2020-01-12', sequence=3) + line4 = self.create_bank_transaction(4, '2020-01-12', sequence=2) + line1 = self.create_bank_transaction(1, '2020-01-13') + line3 = self.create_bank_transaction(3, '2020-01-12', sequence=1) + + # Split the last 2 lines by splitting on the line before last. + statement1 = self.env['account.bank.statement'].with_context({'split_line_id': line7.id}).create({}) + self.assertRecordValues(statement1, [{ + 'is_complete': True, + 'balance_end_real': 15, + 'balance_end': 15, + 'balance_start': 0, + }]) + self.assertRecordValues(line7 + line8, [ + {'amount': 7, 'statement_id': statement1.id}, + {'amount': 8, 'statement_id': statement1.id}, + ]) + + # Split on a line adjutant to another statement + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement2 = self.env['account.bank.statement'].with_context({'split_line_id': line6.id}).create({}) + self.assertRecordValues(statement1, [{ + 'is_complete': True, + 'balance_end_real': 15, + 'balance_start': 0, + }]) + self.assertRecordValues(statement2, [{ + 'is_complete': True, + 'balance_end_real': 21, + 'balance_start': 15, + }]) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': False}, + {'amount': 3, 'running_balance': 33, 'statement_id': False}, + {'amount': 4, 'running_balance': 30, 'statement_id': False}, + {'amount': 5, 'running_balance': 26, 'statement_id': False}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement2.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': statement1.id}, + {'amount': 8, 'running_balance': 8, 'statement_id': statement1.id}, + ], + ) + + # Split on a line with a gap to another statement + statement1.unlink() + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement3 = self.env['account.bank.statement'].with_context({'split_line_id': line3.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': False}, + {'amount': 3, 'running_balance': 33, 'statement_id': statement3.id}, + {'amount': 4, 'running_balance': 30, 'statement_id': statement3.id}, + {'amount': 5, 'running_balance': 26, 'statement_id': statement3.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement2.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': False}, + {'amount': 8, 'running_balance': 8, 'statement_id': False}, + ], + ) + # Split on a line with a single line statement + statement4 = self.env['account.bank.statement'].with_context({'split_line_id': line6.id}).create({}) + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': False}, + {'amount': 3, 'running_balance': 33, 'statement_id': statement3.id}, + {'amount': 4, 'running_balance': 30, 'statement_id': statement3.id}, + {'amount': 5, 'running_balance': 26, 'statement_id': statement3.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement4.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': statement4.id}, + {'amount': 8, 'running_balance': 8, 'statement_id': statement4.id}, + ], + ) + # check double split on a single line + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement5 = self.env['account.bank.statement'].with_context({'split_line_id': line2.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': statement5.id}, + {'amount': 3, 'running_balance': 33, 'statement_id': statement3.id}, + {'amount': 4, 'running_balance': 30, 'statement_id': statement3.id}, + {'amount': 5, 'running_balance': 26, 'statement_id': statement3.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement4.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': statement4.id}, + {'amount': 8, 'running_balance': 8, 'statement_id': statement4.id}, + ], + ) + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement6 = self.env['account.bank.statement'].with_context({'split_line_id': line2.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': statement6.id}, + {'amount': 3, 'running_balance': 33, 'statement_id': statement3.id}, + {'amount': 4, 'running_balance': 30, 'statement_id': statement3.id}, + {'amount': 5, 'running_balance': 26, 'statement_id': statement3.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement4.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': statement4.id}, + {'amount': 8, 'running_balance': 8, 'statement_id': statement4.id}, + ], + ) + + # Split in the middle of a statement + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement7 = self.env['account.bank.statement'].with_context({'split_line_id': line4.id}).create({}) + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'running_balance': 36, 'statement_id': False}, + {'amount': 2, 'running_balance': 35, 'statement_id': statement6.id}, + {'amount': 3, 'running_balance': 33, 'statement_id': statement3.id}, + {'amount': 4, 'running_balance': 30, 'statement_id': statement7.id}, + {'amount': 5, 'running_balance': 26, 'statement_id': statement7.id}, + {'amount': 6, 'running_balance': 21, 'statement_id': statement4.id}, + {'amount': 7, 'running_balance': 15, 'statement_id': statement4.id}, + {'amount': 8, 'running_balance': 8, 'statement_id': statement4.id}, + ], + ) + self.assertRecordValues(statement3 + statement7, [ + {'balance_end_real': 33, 'balance_start': 30, 'is_complete': True, 'is_valid': True, }, + {'balance_end_real': 30, 'balance_start': 21, 'is_complete': True, 'is_valid': True, }, + ]) + + # split at start of another statement + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement8 = self.env['account.bank.statement'].with_context({'split_line_id': line6.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'statement_id': False}, + {'amount': 2, 'statement_id': statement6.id}, + {'amount': 3, 'statement_id': statement3.id}, + {'amount': 4, 'statement_id': statement7.id}, + {'amount': 5, 'statement_id': statement7.id}, + {'amount': 6, 'statement_id': statement8.id}, + {'amount': 7, 'statement_id': statement8.id}, + {'amount': 8, 'statement_id': statement8.id}, + ], + ) + + # split at end of another statement + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement9 = self.env['account.bank.statement'].with_context({'split_line_id': line8.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'statement_id': False}, + {'amount': 2, 'statement_id': statement6.id}, + {'amount': 3, 'statement_id': statement3.id}, + {'amount': 4, 'statement_id': statement7.id}, + {'amount': 5, 'statement_id': statement7.id}, + {'amount': 6, 'statement_id': statement8.id}, + {'amount': 7, 'statement_id': statement8.id}, + {'amount': 8, 'statement_id': statement9.id}, + ], + ) + self.assertRecordValues(statement8 + statement9, [ + {'balance_end_real': 21, 'balance_start': 8, 'is_complete': True, 'is_valid': True, }, + {'balance_end_real': 8, 'balance_start': 0, 'is_complete': True, 'is_valid': True, }, + ]) + + # split at most recent line + self.env['account.bank.statement.line'].invalidate_model(['running_balance']) + statement10 = self.env['account.bank.statement'].with_context({'split_line_id': line1.id}).create({}) + self.assertRecordValues( + self.env['account.bank.statement.line'].search([('company_id', '=', self.env.company.id)]), + [ + # pylint: disable=C0326 + {'amount': 1, 'statement_id': statement10.id}, + {'amount': 2, 'statement_id': statement6.id}, + {'amount': 3, 'statement_id': statement3.id}, + {'amount': 4, 'statement_id': statement7.id}, + {'amount': 5, 'statement_id': statement7.id}, + {'amount': 6, 'statement_id': statement8.id}, + {'amount': 7, 'statement_id': statement8.id}, + {'amount': 8, 'statement_id': statement9.id}, + ], + ) + + all_statements = self.env['account.bank.statement'].search([ + ('line_ids', '!=', False), + ('company_id', '=', self.env.company.id), + ]) + all_statements.invalidate_recordset(['is_valid']) + self.assertEqual(len(all_statements), 6) + self.assertEqual(all_statements.mapped('is_valid'), [True] * len(all_statements)) + self.assertEqual(all_statements.mapped('is_complete'), [True] * len(all_statements)) diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 46e708eae32..95d63289656 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -212,14 +212,12 @@ class TestAccountMove(AccountTestInvoicingCommon): self.test_move.action_post() # Create a bank statement to get a balance in the suspense account. - statement = self.env['account.bank.statement'].create({ + self.env['account.bank.statement.line'].create({ 'journal_id': self.company_data['default_journal_bank'].id, 'date': '2016-01-01', - 'line_ids': [ - (0, 0, {'payment_ref': 'test', 'amount': 10.0}) - ], + 'payment_ref': 'test', + 'amount': 10.0, }) - statement.button_post() # You can't lock the fiscal year if there is some unreconciled statement. with self.assertRaises(RedirectWarning), self.cr.savepoint(): diff --git a/addons/account/tests/test_account_payment.py b/addons/account/tests/test_account_payment.py index 64569f97644..a9adc9319d5 100644 --- a/addons/account/tests/test_account_payment.py +++ b/addons/account/tests/test_account_payment.py @@ -747,19 +747,12 @@ class TestAccountPayment(AccountTestInvoicingCommon): 'is_matched': False, }]) - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', + statement_line = self.env['account.bank.statement.line'].create({ + 'payment_ref': '50 to pay', 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [ - (0, 0, { - 'payment_ref': '50 to pay', - 'partner_id': self.partner_a.id, - 'amount': 50.0, - }), - ], + 'partner_id': self.partner_a.id, + 'amount': 50.0, }) - statement.button_post() - statement_line = statement.line_ids # Reconcile without the bank reconciliation widget since the widget is in enterprise. _st_liquidity_lines, st_suspense_lines, _st_other_lines = statement_line\ @@ -780,17 +773,17 @@ class TestAccountPayment(AccountTestInvoicingCommon): payment = AccountPayment.create({ 'journal_id': self.company_data['default_journal_bank'].id, }) - self.assertRegex(payment.name, r'BNK1/\d{4}/\d{2}/0001') + self.assertRegex(payment.name, r'BNK1/\d{4}/00001') with Form(AccountPayment.with_context(default_move_journal_types=('bank', 'cash'))) as payment_form: self.assertEqual(payment_form._values['name'], '/') payment_form.journal_id = self.company_data['default_journal_cash'] - self.assertRegex(payment_form._values['name'], r'CSH1/\d{4}/\d{2}/0001') + self.assertRegex(payment_form._values['name'], r'CSH1/\d{4}/00001') payment_form.journal_id = self.company_data['default_journal_bank'] payment = payment_form.save() self.assertEqual(payment.name, '/') payment.action_post() - self.assertRegex(payment.name, r'BNK1/\d{4}/\d{2}/0002') + self.assertRegex(payment.name, r'BNK1/\d{4}/00002') def test_payment_without_default_company_account(self): """ The purpose of this test is to check the specific behavior when duplicating an inbound payment, then change diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index e29fc59043e..9f2061b4d7a 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -92,74 +92,61 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): #################### # TODO : account_number, partner_name, transaction_type, narration invoice_number = cls.invoice_line_1.move_id.name - cls.bank_st, cls.bank_st_2, cls.cash_st = cls.env['account.bank.statement'].create([ + cls.bank_line_1, cls.bank_line_2,\ + cls.bank_line_3, cls.bank_line_4,\ + cls.bank_line_5, cls.cash_line_1 = cls.env['account.bank.statement.line'].create([ { - 'name': 'test bank journal', 'journal_id': cls.bank_journal.id, - 'line_ids': [ - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'invoice %s-%s' % tuple(invoice_number.split('/')[1:]), - 'partner_id': cls.partner_1.id, - 'amount': 100, - 'sequence': 1, - }), - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'xxxxx', - 'partner_id': cls.partner_1.id, - 'amount': 600, - 'sequence': 2, - }), - ], - }, { - 'name': 'second test bank journal', + 'date': '2020-01-01', + 'payment_ref': 'invoice %s-%s' % tuple(invoice_number.split('/')[1:]), + 'partner_id': cls.partner_1.id, + 'amount': 100, + 'sequence': 1, + }, + { 'journal_id': cls.bank_journal.id, - 'line_ids': [ - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'nawak', - 'narration': 'Communication: RF12 3456', - 'partner_id': cls.partner_3.id, - 'amount': 600, - 'sequence': 1, - }), - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'RF12 3456', - 'partner_id': cls.partner_3.id, - 'amount': 600, - 'sequence': 2, - }), - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'baaaaah', - 'ref': 'RF12 3456', - 'partner_id': cls.partner_3.id, - 'amount': 600, - 'sequence': 2, - }), - ], - }, { - 'name': 'test cash journal', + 'date': '2020-01-01', + 'payment_ref': 'xxxxx', + 'partner_id': cls.partner_1.id, + 'amount': 600, + 'sequence': 2, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'nawak', + 'narration': 'Communication: RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 1, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'baaaaah', + 'ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }, + { 'journal_id': cls.cash_journal.id, - 'line_ids': [ - (0, 0, { - 'date': '2020-01-01', - 'payment_ref': 'yyyyy', - 'partner_id': cls.partner_2.id, - 'amount': -1000, - 'sequence': 1, - }), - ], - } + 'date': '2020-01-01', + 'payment_ref': 'yyyyy', + 'partner_id': cls.partner_2.id, + 'amount': -1000, + 'sequence': 1, + }, ]) - cls.bank_line_1, cls.bank_line_2 = cls.bank_st.line_ids - cls.bank_line_3, cls.bank_line_4, cls.bank_line_5 = cls.bank_st_2.line_ids - cls.cash_line_1 = cls.cash_st.line_ids - cls._post_statements(cls) - @classmethod def _create_invoice_line(cls, amount, partner, move_type, currency=None, pay_reference=None, ref=None, name=None, inv_date='2019-09-01'): ''' Create an invoice on the fly.''' @@ -185,20 +172,15 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): @classmethod def _create_st_line(cls, amount=1000.0, date='2019-01-01', payment_ref='turlututu', **kwargs): - st = cls.env['account.bank.statement'].create({ - 'name': 'test_allow_payment_tolerance_1', + st_line = cls.env['account.bank.statement.line'].create({ 'journal_id': kwargs.get('journal_id', cls.bank_journal.id), - 'line_ids': [Command.create({ - 'amount': amount, - 'date': date, - 'payment_ref': payment_ref, - 'partner_id': cls.partner_a.id, - **kwargs, - })], + 'amount': amount, + 'date': date, + 'payment_ref': payment_ref, + 'partner_id': cls.partner_a.id, + **kwargs, }) - st.balance_end_real = st.balance_end - st.button_post() - return st.line_ids + return st_line @classmethod def _create_reconcile_model(cls, **kwargs): @@ -224,12 +206,6 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): ], }) - def _post_statements(self): - self.bank_st.balance_end_real = self.bank_st.balance_end - self.bank_st_2.balance_end_real = self.bank_st_2.balance_end - self.cash_st.balance_end_real = self.cash_st.balance_end - (self.bank_st + self.bank_st_2 + self.cash_st).button_post() - @freeze_time('2020-01-01') def _check_statement_matching(self, rules, expected_values_list): for statement_line, expected_values in expected_values_list.items(): @@ -321,7 +297,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): }) def test_matching_fields_match_journal_ids(self): - self.rule_1.match_journal_ids |= self.cash_st.journal_id + self.rule_1.match_journal_ids |= self.cash_line_1.journal_id self._check_statement_matching(self.rule_1, { self.bank_line_1: {}, self.bank_line_2: {}, @@ -938,24 +914,15 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): 'past_months_limit': False, }) - statement = self.env['account.bank.statement'].create({ - 'name': 'test_match_multi_currencies', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': journal.id, - 'line_ids': [ - (0, 0, { - 'journal_id': journal.id, - 'date': '2016-01-01', - 'payment_ref': 'line', - 'partner_id': partner.id, - 'foreign_currency_id': self.currency_data_2['currency'].id, - 'amount': 300.0, # Rate is 3 GOL = 1 USD in 2016. - 'amount_currency': 900.0, # Rate is 10 DAR = 1 USD in 2016 but the rate used by the bank is 9:1. - }), - ], + 'date': '2016-01-01', + 'payment_ref': 'line', + 'partner_id': partner.id, + 'foreign_currency_id': self.currency_data_2['currency'].id, + 'amount': 300.0, # Rate is 3 GOL = 1 USD in 2016. + 'amount_currency': 900.0, # Rate is 10 DAR = 1 USD in 2016 but the rate used by the bank is 9:1. }) - statement_line = statement.line_ids - - statement.button_post() move = self.env['account.move'].create({ 'move_type': 'entry', diff --git a/addons/account/views/account_bank_statement_views.xml b/addons/account/views/account_bank_statement_views.xml index 698a9d26923..23682eb9bc5 100644 --- a/addons/account/views/account_bank_statement_views.xml +++ b/addons/account/views/account_bank_statement_views.xml @@ -2,83 +2,14 @@- A bank statement line is a financial transaction on a bank account -
- Odoo allows you to reconcile a statement line directly with - the related sale or purchase invoice(s). -
-@@ -416,9 +112,7 @@
A Cash Register allows you to manage cash entries in your cash journals. This feature provides an easy way to follow up cash - payments on a daily basis. You can enter the coins that are in - your cash box, and then post entries when money comes in or - goes out of the cash box. + payments on a daily basis.