from datetime import timedelta from odoo import Command, api, fields, models, _ from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools import format_list class AccountSecureEntries(models.TransientModel): """ This wizard is used to secure journal entries (with a hash) """ _name = 'account.secure.entries.wizard' _description = 'Secure Journal Entries' company_id = fields.Many2one( comodel_name='res.company', required=True, readonly=True, default=lambda self: self.env.company, ) country_code = fields.Char( related="company_id.account_fiscal_country_id.code", ) hash_date = fields.Date( string='Hash All Entries', required=True, compute="_compute_hash_date", store=True, readonly=False, help="The selected Date", ) chains_to_hash_with_gaps = fields.Json( compute='_compute_data', ) max_hash_date = fields.Date( string='Max Hash Date', compute='_compute_max_hash_date', help="Highest Date such that all posted journal entries prior to (including) the date are secured. Only journal entries after the hard lock date are considered.", ) unreconciled_bank_statement_line_ids = fields.Many2many( compute='_compute_data', comodel_name='account.bank.statement.line', help="All unreconciled bank statement lines before the selected date.", ) not_hashable_unlocked_move_ids = fields.Many2many( compute='_compute_data', comodel_name='account.move', help="All unhashable moves before the selected date that are not protected by the Hard Lock Date", ) move_to_hash_ids = fields.Many2many( compute='_compute_data', comodel_name='account.move', help="All moves that will be hashed", ) warnings = fields.Json( compute='_compute_warnings', ) @api.depends('max_hash_date') def _compute_hash_date(self): for wizard in self: if not wizard.hash_date: wizard.hash_date = wizard.max_hash_date or fields.Date.context_today(self) @api.depends('company_id', 'company_id.user_hard_lock_date') def _compute_max_hash_date(self): today = fields.Date.context_today(self) for wizard in self: chains_to_hash = wizard._get_chains_to_hash(wizard.company_id, today) moves = self.env['account.move'].concat( *[chain['moves'] for chain in chains_to_hash], *[chain['not_hashable_unlocked_moves'] for chain in chains_to_hash], ) if moves: wizard.max_hash_date = min(move.date for move in moves) - timedelta(days=1) else: wizard.max_hash_date = False @api.model def _get_chains_to_hash(self, company_id, hash_date): self.ensure_one() res = [] moves = self.env['account.move'].sudo().search( self._get_unhashed_moves_in_hashed_period_domain(company_id, hash_date, [('state', '=', 'posted')]) ) for journal, journal_moves in moves.grouped('journal_id').items(): for chain_moves in journal_moves.grouped('sequence_prefix').values(): chain_info = chain_moves._get_chain_info(force_hash=True) if not chain_info: continue last_move_hashed = chain_info['last_move_hashed'] # It is possible that some moves cannot be hashed (i.e. after upgrade). # We show a warning ('account_not_hashable_unlocked_moves') if that is the case. # These moves are ignored for the warning and max_hash_date in case they are protected by the Hard Lock Date if last_move_hashed: # remaining_moves either have a hash already or have a higher sequence_number than the last_move_hashed not_hashable_unlocked_moves = chain_info['remaining_moves'].filtered( lambda move: (not move.inalterable_hash and move.sequence_number < last_move_hashed.sequence_number and move.date > self.company_id.user_hard_lock_date) ) else: not_hashable_unlocked_moves = self.env['account.move'] chain_info['not_hashable_unlocked_moves'] = not_hashable_unlocked_moves res.append(chain_info) return res @api.depends('company_id', 'company_id.user_hard_lock_date', 'hash_date') def _compute_data(self): for wizard in self: unreconciled_bank_statement_line_ids = [] chains_to_hash = [] if wizard.hash_date: for chain_info in wizard._get_chains_to_hash(wizard.company_id, wizard.hash_date): if 'unreconciled' in chain_info['warnings']: unreconciled_bank_statement_line_ids.extend( chain_info['moves'].statement_line_ids.filtered(lambda l: not l.is_reconciled).ids ) else: chains_to_hash.append(chain_info) wizard.unreconciled_bank_statement_line_ids = [Command.set(unreconciled_bank_statement_line_ids)] wizard.chains_to_hash_with_gaps = [ { 'first_move_id': chain['moves'][0].id, 'last_move_id': chain['moves'][-1].id, } for chain in chains_to_hash if 'gap' in chain['warnings'] ] not_hashable_unlocked_moves = [] move_to_hash_ids = [] for chain in chains_to_hash: not_hashable_unlocked_moves.extend(chain['not_hashable_unlocked_moves'].ids) move_to_hash_ids.extend(chain['moves'].ids) wizard.not_hashable_unlocked_move_ids = [Command.set(not_hashable_unlocked_moves)] wizard.move_to_hash_ids = [Command.set(move_to_hash_ids)] @api.depends('company_id', 'chains_to_hash_with_gaps', 'hash_date', 'not_hashable_unlocked_move_ids', 'max_hash_date', 'unreconciled_bank_statement_line_ids') def _compute_warnings(self): for wizard in self: warnings = {} if not wizard.hash_date: wizard.warnings = warnings continue if wizard.unreconciled_bank_statement_line_ids: ignored_sequence_prefixes = list(set(wizard.unreconciled_bank_statement_line_ids.move_id.mapped('sequence_prefix'))) warnings['account_unreconciled_bank_statement_line_ids'] = { 'message': _("There are still unreconciled bank statement lines before the selected date. " "The entries from journal prefixes containing them will not be secured: %(prefix_info)s", prefix_info=format_list(self.env, ignored_sequence_prefixes)), 'level': 'danger', 'action_text': _("Review"), 'action': wizard.company_id._get_unreconciled_statement_lines_redirect_action(wizard.unreconciled_bank_statement_line_ids), } draft_entries = self.env['account.move'].search_count( wizard._get_draft_moves_in_hashed_period_domain(), limit=1 ) if draft_entries: warnings['account_unhashed_draft_entries'] = { 'message': _("There are still draft entries before the selected date."), 'action_text': _("Review"), 'action': wizard.action_show_draft_moves_in_hashed_period(), } not_hashable_unlocked_moves = wizard.not_hashable_unlocked_move_ids if not_hashable_unlocked_moves: warnings['account_not_hashable_unlocked_moves'] = { 'message': _("There are entries that cannot be hashed. They can be protected by the Hard Lock Date."), 'action_text': _("Review"), 'action': wizard.action_show_moves(not_hashable_unlocked_moves), } if wizard.chains_to_hash_with_gaps: OR_domains = [] for chain in wizard.chains_to_hash_with_gaps: first_move = self.env['account.move'].browse(chain['first_move_id']) last_move = self.env['account.move'].browse(chain['last_move_id']) OR_domains.append([ *self.env['account.move']._check_company_domain(wizard.company_id), ('journal_id', '=', last_move.journal_id.id), ('sequence_prefix', '=', last_move.sequence_prefix), ('sequence_number', '<=', last_move.sequence_number), ('sequence_number', '>=', first_move.sequence_number), ]) domain = expression.OR(OR_domains) warnings['account_sequence_gap'] = { 'message': _("Securing these entries will create at least one gap in the sequence."), 'action_text': _("Review"), 'action': { **self.env['account.journal']._show_sequence_holes(domain), 'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']], } } moves_to_hash_after_selected_date = wizard.move_to_hash_ids.filtered(lambda move: move.date > wizard.hash_date) if moves_to_hash_after_selected_date: warnings['account_move_to_secure_after_selected_date'] = { 'message': _("Securing these entries will also secure entries after the selected date."), 'action_text': _("Review"), 'action': wizard.action_show_moves(moves_to_hash_after_selected_date), } wizard.warnings = warnings @api.model def _get_unhashed_moves_in_hashed_period_domain(self, company_id, hash_date, domain=False): """ Return the domain to find all moves before `self.hash_date` that have not been hashed yet. We ignore whether hashing is activated for the journal or not. :return a search domain """ if not (company_id and hash_date): return [(0, '=', 1)] return expression.AND([ [ ('date', '<=', fields.Date.to_string(hash_date)), ('company_id', 'child_of', company_id.id), ('inalterable_hash', '=', False), ], domain or [], ]) def _get_draft_moves_in_hashed_period_domain(self): self.ensure_one() return self._get_unhashed_moves_in_hashed_period_domain(self.company_id, self.hash_date, [('state', '=', 'draft')]) def action_show_moves(self, moves): self.ensure_one() return { 'view_mode': 'list', 'name': _('Journal Entries'), 'res_model': 'account.move', 'type': 'ir.actions.act_window', 'domain': [('id', 'in', moves.ids)], 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'], 'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']], } def action_show_draft_moves_in_hashed_period(self): self.ensure_one() return { 'view_mode': 'list', 'name': _('Draft Entries'), 'res_model': 'account.move', 'type': 'ir.actions.act_window', 'domain': self._get_draft_moves_in_hashed_period_domain(), 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'], 'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']], } def action_secure_entries(self): self.ensure_one() if not self.hash_date: raise UserError(_("Set a date. The moves will be secured up to including this date.")) self.env['res.groups']._activate_group_account_secured() if not self.move_to_hash_ids: return self.move_to_hash_ids._hash_moves(force_hash=True, raise_if_gap=False)