From 4fb0933986534e31337aa1dce3478a67deea72de Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 11 Aug 2022 09:37:21 +0000 Subject: [PATCH] [IMP] account: Optional bank statements Bank statements are now optional in accounting. The users add statements directly and can assign statements for control reasons. The users can enter end date and end balance real for the statements and attach the bank statement scan. The system checks if there is a gap (lines without statement) before current statement and warns the user with decoration, also we check if the ending balance match the accumulated balance of the last line. Bank statement lines now show an accumulated balance and can be sorted by drag and drop in the same date, if the user drags a line to a position with lines of another date, it simply doesn't have any effect. Rename accumulated balance to cumulative balance, a better name closes odoo/odoo#99092 Task: 2879904 Related: odoo/upgrade#3906 Related: odoo/enterprise#30824 Signed-off-by: Laurent Smet --- addons/account/__manifest__.py | 1 - addons/account/demo/account_demo.py | 33 +- addons/account/models/__init__.py | 1 + .../account/models/account_bank_statement.py | 1285 +++-------------- .../models/account_bank_statement_line.py | 806 +++++++++++ addons/account/models/account_journal.py | 2 +- .../models/account_journal_dashboard.py | 27 +- addons/account/models/account_move.py | 20 +- addons/account/models/account_payment.py | 29 +- addons/account/models/chart_template.py | 2 +- addons/account/models/company.py | 12 +- .../populate/account_bank_statement.py | 127 +- addons/account/security/ir.model.access.csv | 4 - .../tests/test_account_bank_statement.py | 1047 ++++++++------ .../account/tests/test_account_move_entry.py | 8 +- addons/account/tests/test_account_payment.py | 21 +- .../test_reconciliation_matching_rules.py | 161 +-- .../views/account_bank_statement_views.xml | 332 +---- .../views/account_journal_dashboard_view.xml | 18 +- addons/account/views/account_move_views.xml | 13 +- addons/account/views/account_payment_view.xml | 10 +- addons/account/wizard/__init__.py | 1 - addons/account/wizard/pos_box.py | 53 - addons/account/wizard/pos_box.xml | 23 - addons/hr_expense/tests/test_expenses.py | 15 +- addons/l10n_fr_pos_cert/__manifest__.py | 1 - .../l10n_fr_pos_cert/views/account_views.xml | 26 - .../models/account_bank_statement.py | 9 - addons/point_of_sale/models/pos_config.py | 2 +- addons/point_of_sale/models/pos_order.py | 4 +- addons/point_of_sale/models/pos_session.py | 42 +- .../static/src/xml/Popups/ClosePosPopup.xml | 2 +- .../tests/test_point_of_sale_flow.py | 47 - .../point_of_sale/views/pos_session_view.xml | 10 - .../static/tests/tours/main_flow.js | 99 +- 35 files changed, 1905 insertions(+), 2388 deletions(-) create mode 100644 addons/account/models/account_bank_statement_line.py delete mode 100644 addons/account/wizard/pos_box.py delete mode 100644 addons/account/wizard/pos_box.xml delete mode 100644 addons/l10n_fr_pos_cert/views/account_views.xml 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 @@ - - account.bnk_stmt_cashbox.form - account.bank.statement.cashbox - -
- - - - - - - - - - - - -
- - - -
- -
-
-
-
-
-
-
- - - account.bnk_stmt_cashbox.form - account.bank.statement.cashbox - 1000 - primary - - - -
-
-
-
-
- - - account.bnk_stmt_check.form - account.bank.statement.closebalance - -
-
-

The closing balance is different than the computed one!

-

Confirming this will create automatically a journal entry with the difference in the profit/loss account set on the cash journal.

-
-
-
-
-
-
- - - Check Closing Balance - account.bank.statement.closebalance - tree,form - - new - account.bank.statement.tree account.bank.statement - + @@ -87,8 +18,8 @@ - - + + @@ -100,154 +31,26 @@ - - - + + - - - account.bank.statement.form - account.bank.statement - 1 - -
-
-
- - - - - - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- Create Statements -
-
+
New Transaction diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index a591fd7b60e..20b7f26aaba 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -32,7 +32,7 @@ - + @@ -196,7 +196,7 @@ - + @@ -674,13 +674,6 @@
- - diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py index 4f371a2783b..6de1f43c910 100644 --- a/addons/account/wizard/__init__.py +++ b/addons/account/wizard/__init__.py @@ -5,7 +5,6 @@ from . import account_automatic_entry_wizard from . import account_unreconcile from . import account_validate_account_move -from . import pos_box from . import account_move_reversal from . import account_resequence from . import setup_wizards diff --git a/addons/account/wizard/pos_box.py b/addons/account/wizard/pos_box.py deleted file mode 100644 index 154ea01740e..00000000000 --- a/addons/account/wizard/pos_box.py +++ /dev/null @@ -1,53 +0,0 @@ -from odoo import models, fields, api, _ -from odoo.exceptions import UserError - -class CashBox(models.TransientModel): - _register = False - - name = fields.Char(string='Reason', required=True) - # Attention, we don't set a domain, because there is a journal_type key - # in the context of the action - amount = fields.Float(string='Amount', digits=0, required=True) - - def run(self): - context = dict(self._context or {}) - active_model = context.get('active_model', False) - active_ids = context.get('active_ids', []) - - records = self.env[active_model].browse(active_ids) - - return self._run(records) - - def _run(self, records): - for box in self: - for record in records: - if not record.journal_id: - raise UserError(_("Please check that the field 'Journal' is set on the Bank Statement")) - if not record.journal_id.company_id.transfer_account_id: - raise UserError(_("Please check that the field 'Transfer Account' is set on the company.")) - box._create_bank_statement_line(record) - return {} - - def _create_bank_statement_line(self, record): - for box in self: - if record.state == 'confirm': - raise UserError(_("You cannot put/take money in/out for a bank statement which is closed.")) - values = box._calculate_values_for_statement_line(record) - self.env['account.bank.statement.line'].sudo().create(values) - - -class CashBoxOut(CashBox): - _name = 'cash.box.out' - _description = 'Cash Box Out' - - def _calculate_values_for_statement_line(self, record): - if not record.journal_id.company_id.transfer_account_id: - raise UserError(_("You have to define an 'Internal Transfer Account' in your cash register's journal.")) - amount = self.amount or 0.0 - return { - 'date': record.date, - 'statement_id': record.id, - 'journal_id': record.journal_id.id, - 'amount': amount, - 'payment_ref': self.name, - } diff --git a/addons/account/wizard/pos_box.xml b/addons/account/wizard/pos_box.xml deleted file mode 100644 index 8b1921590e2..00000000000 --- a/addons/account/wizard/pos_box.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - cash_box_out - cash.box.out - -
- - - - - - -
-
- -
-
-
-
diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index 6c3cdf213ba..3d148947e09 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -85,19 +85,12 @@ class TestExpenses(TestExpenseCommon): in_payment_state = expense_sheet.account_move_id._get_invoice_in_payment_state() self.assertEqual(expense_sheet.payment_state, in_payment_state, 'payment_state should be ' + in_payment_state) - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', + statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': self.company_data['default_journal_bank'].id, - 'line_ids': [ - (0, 0, { - 'payment_ref': 'pay_ref', - 'amount': -350.0, - 'partner_id': self.expense_employee.address_home_id.id, - }), - ], + 'payment_ref': 'pay_ref', + 'amount': -350.0, + 'partner_id': self.expense_employee.address_home_id.id, }) - 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\ diff --git a/addons/l10n_fr_pos_cert/__manifest__.py b/addons/l10n_fr_pos_cert/__manifest__.py index 84d42429a42..2798e3bf713 100644 --- a/addons/l10n_fr_pos_cert/__manifest__.py +++ b/addons/l10n_fr_pos_cert/__manifest__.py @@ -26,7 +26,6 @@ The module adds following features: 'installable': True, 'auto_install': True, 'data': [ - 'views/account_views.xml', 'views/pos_views.xml', 'views/account_sale_closure.xml', 'views/pos_inalterability_menuitem.xml', diff --git a/addons/l10n_fr_pos_cert/views/account_views.xml b/addons/l10n_fr_pos_cert/views/account_views.xml deleted file mode 100644 index 94be33418a3..00000000000 --- a/addons/l10n_fr_pos_cert/views/account_views.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - account.bank.statement.form - account.bank.statement - - extension - - - - - - {'readonly': [('pos_session_id', '!=', False)]} - - - {'readonly': ['|', ('move_line_count','!=', 0), ('pos_session_id', '!=', False)]} - - - {'readonly': ['|', ('state', '!=', 'open'), ('pos_session_id', '!=', False)]} - - - {'readonly': ['|', ('state', '!=', 'open'), ('pos_session_id', '!=', False)]} - - - - - diff --git a/addons/point_of_sale/models/account_bank_statement.py b/addons/point_of_sale/models/account_bank_statement.py index 8987cfc6fc7..1801fddc77a 100644 --- a/addons/point_of_sale/models/account_bank_statement.py +++ b/addons/point_of_sale/models/account_bank_statement.py @@ -9,15 +9,6 @@ class AccountBankStatementLine(models.Model): _inherit = 'account.bank.statement.line' pos_session_id = fields.Many2one('pos.session', string="Session", copy=False) - # TODO:PoMa check if they need to be converted - # account_id = fields.Many2one('account.account', related='journal_id.default_account_id', readonly=True) - - # def button_validate_or_action(self): - # # OVERRIDE to check the consistency of the statement's state regarding the session's state. - # for statement in self: - # if statement.pos_session_id.state in ('opened', 'closing_control') and statement.state == 'open': - # raise UserError(_("You can't validate a bank statement that is used in an opened Session of a Point of Sale.")) - # return super(AccountBankStatement, self).button_validate_or_action() @api.ondelete(at_uninstall=False) def _unlink_except_linked_to_pos_session(self): diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 68a2ac2e6ce..749457aa5d4 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -219,7 +219,7 @@ class PosConfig(models.Model): for pos_config in self: session = PosSession.search_read( [('config_id', '=', pos_config.id), ('state', '=', 'closed')], - ['cash_register_balance_end_real', 'stop_at', 'cash_journal_id'], + ['cash_register_balance_end_real', 'stop_at'], order="stop_at desc", limit=1) if session: timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC') diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 9ab5cb2d14b..0056d6b7316 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -170,7 +170,7 @@ class PosOrder(models.Model): order_bank_statement_lines= self.env['pos.payment'].search([('pos_order_id', '=', order.id)]) order_bank_statement_lines.unlink() - for payments in pos_order['statement_ids']: # this is not related to session.statement_ids it is just a variable with a weird name holding payments + for payments in pos_order['statement_ids']: order.add_payment(self._payment_fields(order, payments[2])) order.amount_paid = sum(order.payment_ids.mapped('amount')) @@ -1011,7 +1011,7 @@ class PosOrder(models.Model): timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC') return { 'lines': [[0, 0, line] for line in order.lines.export_for_ui()], - 'statement_ids': [[0, 0, payment] for payment in order.payment_ids.export_for_ui()], # this is not related to session.statement_ids it is just a variable with a weird name holding payments + 'statement_ids': [[0, 0, payment] for payment in order.payment_ids.export_for_ui()], 'name': order.pos_reference, 'uid': re.search('([0-9-]){14}', order.pos_reference).group(0), 'amount_paid': order.amount_paid, diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 5e509ce2665..d86da9b40ec 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -53,7 +53,7 @@ class PosSession(models.Model): login_number = fields.Integer(string='Login Sequence Number', help='A sequence number that is incremented each time a user resumes the pos session', default=0) opening_notes = fields.Text(string="Opening Notes") - cash_control = fields.Boolean(compute='_compute_cash_all', string='Has Cash Control', compute_sudo=True, store=True) + cash_control = fields.Boolean(compute='_compute_cash_all', string='Has Cash Control', compute_sudo=True) cash_journal_id = fields.Many2one('account.journal', compute='_compute_cash_all', string='Cash Journal', store=True) cash_register_balance_end_real = fields.Monetary( @@ -175,14 +175,6 @@ class PosSession(models.Model): if (company.period_lock_date and start_date <= company.period_lock_date) or (company.fiscalyear_lock_date and start_date <= company.fiscalyear_lock_date): raise ValidationError(_("You cannot create a session before the accounting lock date.")) - def _check_bank_statement_state(self): - for session in self: - posted_stmt_lines = session.statement_line_ids.filtered(lambda x: x.state == "posted") - if posted_stmt_lines: - raise UserError(_("Some Cash Transactions are already posted. " - "Please reset them to new in order to close the session.\n" - "Cash Registers: %r", list(stmt_line.name for stmt_line in posted_stmt_lines))) - def _check_invoices_are_posted(self): unposted_invoices = self.order_ids.sudo().with_company(self.company_id).account_move.filtered(lambda x: x.state != 'posted') if unposted_invoices: @@ -247,8 +239,7 @@ class PosSession(models.Model): session.cash_register_balance_start = last_session.cash_register_balance_end_real # defaults to 0 if lastsession is empty else: values['state'] = 'opened' - if values: - session.write(values) + session.write(values) return True def action_pos_session_closing_control(self, balancing_account=False, amount_to_balance=0, bank_payment_method_diffs=None): @@ -266,7 +257,6 @@ class PosSession(models.Model): if session.rescue and session.config_id.cash_control: default_cash_payment_method_id = self.payment_method_ids.filtered(lambda pm: pm.type == 'cash')[0] orders = self.order_ids.filtered(lambda o: o.state == 'paid' or o.state == 'invoiced') - total_cash = sum( orders.payment_ids.filtered(lambda p: p.payment_method_id == default_cash_payment_method_id).mapped('amount') ) + self.cash_register_balance_start @@ -285,7 +275,6 @@ class PosSession(models.Model): # Session without cash payment method will not have a cash register. # However, there could be other payment methods, thus, session still # needs to be validated. - self._check_bank_statement_state() return self._validate_session(balancing_account, amount_to_balance, bank_payment_method_diffs) def _validate_session(self, balancing_account=False, amount_to_balance=0, bank_payment_method_diffs=None): @@ -333,7 +322,6 @@ class PosSession(models.Model): self.sudo().with_company(self.company_id)._reconcile_account_move_lines(data) else: if self.config_id.cash_control: - st_line_vals = { 'journal_id': self.cash_journal_id.id, 'amount': self.cash_register_difference, @@ -402,12 +390,6 @@ class PosSession(models.Model): if check_closing_session: return check_closing_session - # For now we won't simply do - # self._check_pos_session_balance() - # self._check_bank_statement_state() - # validate_result = self._validate_session() - # because some functions are being used and overridden in other modules... - # so we'll try to use the original flow as of now for the moment validate_result = self.action_pos_session_closing_control(bank_payment_method_diffs=bank_payment_method_diffs) # If an error is raised, the user will still be redirected to the back end to manually close the session. @@ -555,7 +537,7 @@ class PosSession(models.Model): 'name': default_cash_payment_method_id.name, 'amount': self.cash_register_balance_start + total_default_cash_payment_amount - + self.cash_real_transaction, + + sum(self.statement_line_ids.mapped('amount')), 'opening': self.cash_register_balance_start, 'payment_amount': total_default_cash_payment_amount, 'moves': cash_in_out_list, @@ -1399,16 +1381,6 @@ class PosSession(models.Model): # self should be single record as this method is only called in the subfunctions of self._validate_session return self.currency_id._convert(amount, self.company_id.currency_id, self.company_id, date, round=round) - def show_cash_register(self): - self.ensure_one() - return { - 'name': _('Cash register for %s') % (self.name, ), - 'type': 'ir.actions.act_window', - 'res_model': 'account.bank.statement.line', - 'view_mode': 'tree', - 'domain': [('pos_session_id', '=', self.id)], - } - def show_journal_items(self): self.ensure_one() all_related_moves = self._get_related_account_moves() @@ -1542,14 +1514,16 @@ class PosSession(models.Model): if not sessions: raise UserError(_("There is no cash payment method for this PoS Session")) - for session in sessions: - self.env['account.bank.statement.line'].create({ + self.env['account.bank.statement.line'].create([ + { 'pos_session_id': session.id, 'journal_id': session.cash_journal_id.id, 'amount': sign * amount, 'date': fields.Date.context_today(self), 'payment_ref': '-'.join([session.name, extras['translatedType'], reason]), - }) + } + for session in sessions + ]) message_content = [f"Cash {extras['translatedType']}", f'- Amount: {extras["formattedAmount"]}'] if reason: diff --git a/addons/point_of_sale/static/src/xml/Popups/ClosePosPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ClosePosPopup.xml index 10a531b54b6..e436f55d2e3 100644 --- a/addons/point_of_sale/static/src/xml/Popups/ClosePosPopup.xml +++ b/addons/point_of_sale/static/src/xml/Popups/ClosePosPopup.xml @@ -40,7 +40,7 @@ Opening - +
diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index a0398864c7c..a731221da68 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -549,53 +549,6 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): # I close the session to generate the journal entries current_session.action_pos_session_closing_control() - """In order to test the reports on Bank Statement defined in point_of_sale module, I create a bank statement line, confirm it and print the reports""" - - # I select the period and journal for the bank statement - # FIXME:poma - # - # context_journal = {'journal_type': 'bank'} - # self.assertTrue(self.AccountBankStatement.with_context( - # context_journal)._default_journal(), 'Journal has not been selected') - # journal = self.env['account.journal'].create({ - # 'name': 'Bank Test', - # 'code': 'BNKT', - # 'type': 'bank', - # 'company_id': self.env.company.id, - # }) - # # I create a bank statement with Opening and Closing balance 0. - # account_statement = self.AccountBankStatement.create({ - # 'balance_start': 0.0, - # 'balance_end_real': 0.0, - # 'date': time.strftime('%Y-%m-%d'), - # 'journal_id': journal.id, - # 'company_id': self.env.company.id, - # 'name': 'pos session test', - # }) - # # I create bank statement line - # account_statement_line = self.AccountBankStatementLine.create({ - # 'amount': 1000, - # 'partner_id': self.partner4.id, - # 'statement_id': account_statement.id, - # 'payment_ref': 'EXT001' - # }) - # # I modify the bank statement and set the Closing Balance. - # account_statement.write({ - # 'balance_end_real': 1000.0, - # }) - # - # # I reconcile the bank statement. - # new_aml_dicts = [{ - # 'account_id': self.partner4.property_account_receivable_id.id, - # 'name': "EXT001", - # 'credit': 1000.0, - # 'debit': 0.0, - # }] - # - # # I confirm the bank statement using Confirm button - # - # self.AccountBankStatement.button_validate() - def test_create_from_ui(self): """ Simulation of sales coming from the interface, even after closing the session diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml index 6ec7130159a..dcb53f7468c 100644 --- a/addons/point_of_sale/views/pos_session_view.xml +++ b/addons/point_of_sale/views/pos_session_view.xml @@ -45,16 +45,6 @@ groups="account.group_account_readonly" > - -

diff --git a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js index b04ce4c236b..20ab5d7a9dc 100644 --- a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js +++ b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js @@ -901,35 +901,33 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p content: _t('Go to Accounting'), position: 'bottom', }, { - // FIXME WOWL: this selector needs to work in both legacy and non-legacy views + mobile: false, edition: "enterprise", - trigger: 'div[name=bank_statement_create_button] > a[data-name=create_bank_statement], div[name=bank_statement_create_button] > a[name=create_bank_statement]', - content: _t('Create a new bank statement'), - position: 'bottom', + trigger: "div.o_account_kanban div.o_kanban_card_header a.oe_kanban_action span:contains('Bank')", + content: _t("Open the bank reconciliation widget"), }, { + mobile: false, edition: "enterprise", - trigger: '.o_field_widget[name=name] input', - content: _t("Let's enter the reference."), - position: 'left', - run: 'text the_flow.statement', + trigger: "button.o_switch_view.o_list", + content: _t("Move to the list view"), }, { + mobile: false, edition: "enterprise", - trigger: ".o_field_widget[name=balance_end_real] input", - content: _t("Let's calculate the ending balance."), - position: 'right', - run: 'text 9955.87', + trigger: "button.o_list_button_add", + content: _t("Create a new bank transaction"), }, { - // FIXME convert list in kanban + form + mobile: false, edition: "enterprise", - trigger: ".o_field_widget[name=line_ids] .o_field_x2many_list_row_add > a", - content: _t("Click here to add some lines."), - position: "bottom", -}, { - edition: "enterprise", - trigger: ".o_selected_row .o_field_widget[name=amount] input", + trigger: '.o_field_widget[name=amount] input', content: _t("Write the amount received."), position: "bottom", run: "text 11.00", +}, { + mobile: false, + edition: "enterprise", + trigger: ".o_selected_row .o_field_widget[name=payment_ref] input", + content: _t("Let's enter a name."), + run: "text the_flow.statement.line", }, { mobile: false, edition: "enterprise", @@ -944,42 +942,49 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p in_modal: false, auto: true, }, { - mobile: true, + mobile: false, edition: "enterprise", - trigger: ".o_selected_row .o_field_widget[name=partner_id] input", - content: _t("Write the name of your customer."), - position: "bottom", -}, -...tour.stepUtils.mobileKanbanSearchMany2X('Partner', 'the_flow.customer'), -{ - edition: "enterprise", - trigger: ".o_selected_row .o_field_widget[name=payment_ref] input", + trigger: '.o_list_button_save', extra_trigger: ".o_selected_row .o_field_widget[name=partner_id] .o_external_button", - content: _t("Let's enter a name."), - position: "bottom", - run: "text the_flow.statement.line", -}, { - edition: "enterprise", - trigger: '.o_form_button_save', content: _t('Save.'), position: 'bottom', -}, -...tour.stepUtils.statusbarButtonsSteps('Post', _t('Processing'), ".breadcrumb-item.active:contains('the_flow.statement')").map(tour.stepUtils.editionEnterpriseModifier), -...tour.stepUtils.statusbarButtonsSteps('Reconcile', _t('Reconcile'), ".o_statusbar_status .btn.dropdown-toggle:contains(Processing)") - .map(function(step){return {mobile: false, ...step}}) - .map(tour.stepUtils.editionEnterpriseModifier), -{ +}, { mobile: false, edition: "enterprise", - trigger: "button[name='button_validate']", - content: Markup(_t('

Click on Reconcile

')), - position: "right", -}, -{ + trigger: "button.o_switch_view.o_kanban", + extra_trigger: ".o_list_button_add", + content: _t("Move back to the kanban view"), +}, { mobile: false, edition: "enterprise", - trigger: ".o_tag_badge_text:contains('Matched')", - auto: true, + trigger: "div.o_bank_rec_st_line_kanban_card span:contains('the_flow.customer')", + extra_trigger: "div.o_bank_rec_st_line_kanban_card span:contains('the_flow.customer')", + content: _t("Select the newly created bank transaction"), +}, { + mobile: false, + edition: "enterprise", + trigger: "button[name='button_validate'].btn-primary", + extra_trigger: "button[name='button_validate'].btn-primary", + content: _t("Reconcile the bank transaction"), +}, { + mobile: false, + edition: "enterprise", + trigger: ".o_control_panel .o_filter_menu .o_dropdown_title:contains('Filters'):first", + extra_trigger: "a.active[name='amls_tab']", + content: _t("Click on 'Filters'"), +}, { + mobile: false, + edition: "enterprise", + trigger: ".o_control_panel .o_filter_menu .dropdown-item:contains('Matched')", + extra_trigger: ".o_control_panel .o_filter_menu .dropdown-item:contains('Matched')", + content: _t("Add the 'Matched' filters"), +}, { + mobile: false, + edition: "enterprise", + trigger: "div.o_bank_rec_st_line_kanban_card span:contains('the_flow.customer')", + extra_trigger: "div.o_bank_rec_st_line_kanban_card span:contains('the_flow.customer')", + content: _t("Ensure the line is well reconciled"), + run: function(){}, }, // exit reconciliation widget {