# Fusion Accounting - Lock Date Management Wizard # Provides UI for managing fiscal, tax, sales, and purchase lock dates # with support for temporary exceptions and draft entry warnings. from datetime import date, timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools import date_utils from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS, LOCK_DATE_FIELDS class AccountChangeLockDate(models.TransientModel): """Wizard that enables administrators to update accounting lock dates and manage exceptions for individual users or the entire organization.""" _name = 'account.change.lock.date' _description = 'Change Lock Date' # --- Core Company Reference --- company_id = fields.Many2one( comodel_name='res.company', required=True, readonly=True, default=lambda self: self.env.company, ) # --- Fiscal Year Lock --- fiscalyear_lock_date = fields.Date( string='Lock Everything', default=lambda self: self.env.company.fiscalyear_lock_date, help="Entries on or before this date are locked and will be " "rescheduled according to the journal's sequence.", ) fiscalyear_lock_date_for_me = fields.Date( string='Lock Everything For Me', compute='_compute_lock_date_exceptions', ) fiscalyear_lock_date_for_everyone = fields.Date( string='Lock Everything For Everyone', compute='_compute_lock_date_exceptions', ) min_fiscalyear_lock_date_exception_for_me_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) min_fiscalyear_lock_date_exception_for_everyone_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) # --- Tax Lock --- tax_lock_date = fields.Date( string="Lock Tax Return", default=lambda self: self.env.company.tax_lock_date, help="Tax entries on or before this date are locked. This date " "updates automatically when a tax closing entry is posted.", ) tax_lock_date_for_me = fields.Date( string='Lock Tax Return For Me', compute='_compute_lock_date_exceptions', ) tax_lock_date_for_everyone = fields.Date( string='Lock Tax Return For Everyone', compute='_compute_lock_date_exceptions', ) min_tax_lock_date_exception_for_me_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) min_tax_lock_date_exception_for_everyone_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) # --- Sales Lock --- sale_lock_date = fields.Date( string='Lock Sales', default=lambda self: self.env.company.sale_lock_date, help="Sales entries on or before this date are locked and will " "be postponed per the journal's sequence.", ) sale_lock_date_for_me = fields.Date( string='Lock Sales For Me', compute='_compute_lock_date_exceptions', ) sale_lock_date_for_everyone = fields.Date( string='Lock Sales For Everyone', compute='_compute_lock_date_exceptions', ) min_sale_lock_date_exception_for_me_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) min_sale_lock_date_exception_for_everyone_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) # --- Purchase Lock --- purchase_lock_date = fields.Date( string='Lock Purchases', default=lambda self: self.env.company.purchase_lock_date, help="Purchase entries on or before this date are locked and " "will be postponed per the journal's sequence.", ) purchase_lock_date_for_me = fields.Date( string='Lock Purchases For Me', compute='_compute_lock_date_exceptions', ) purchase_lock_date_for_everyone = fields.Date( string='Lock Purchases For Everyone', compute='_compute_lock_date_exceptions', ) min_purchase_lock_date_exception_for_me_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) min_purchase_lock_date_exception_for_everyone_id = fields.Many2one( comodel_name='account.lock_exception', compute='_compute_lock_date_exceptions', ) # --- Hard (Irreversible) Lock --- hard_lock_date = fields.Date( string='Hard Lock', default=lambda self: self.env.company.hard_lock_date, help="Entries on or before this date are permanently locked. " "This lock cannot be removed and allows no exceptions.", ) current_hard_lock_date = fields.Date( string='Current Hard Lock', related='company_id.hard_lock_date', readonly=True, ) # --- Exception Configuration --- exception_needed = fields.Boolean( string='Exception needed', compute='_compute_exception_needed', ) exception_needed_fields = fields.Char( compute='_compute_exception_needed_fields', ) exception_applies_to = fields.Selection( string='Exception applies', selection=[ ('me', "for me"), ('everyone', "for everyone"), ], default='me', required=True, ) exception_duration = fields.Selection( string='Exception Duration', selection=[ ('5min', "for 5 minutes"), ('15min', "for 15 minutes"), ('1h', "for 1 hour"), ('24h', "for 24 hours"), ('forever', "forever"), ], default='5min', required=True, ) exception_reason = fields.Char( string='Exception Reason', ) # --- Warning for draft entries --- show_draft_entries_warning = fields.Boolean( string="Show Draft Entries Warning", compute='_compute_show_draft_entries_warning', ) # ------------------------------------------------------------------------- # Compute Methods # ------------------------------------------------------------------------- @api.depends('company_id') @api.depends_context('user', 'company') def _compute_lock_date_exceptions(self): """Retrieve active lock date exceptions for each soft lock field and determine the minimum exception per user scope.""" LockException = self.env['account.lock_exception'] for wiz in self: active_domain = LockException._get_active_exceptions_domain( wiz.company_id, SOFT_LOCK_DATE_FIELDS, ) active_exceptions = LockException.search(active_domain) current_uid = self.env.user.id for lock_field in SOFT_LOCK_DATE_FIELDS: # Partition exceptions by scope (current user vs global) my_exceptions = active_exceptions.filtered( lambda exc: exc.lock_date_field == lock_field and exc.user_id.id == current_uid ) global_exceptions = active_exceptions.filtered( lambda exc: exc.lock_date_field == lock_field and not exc.user_id.id ) # Find the earliest exception in each scope earliest_mine = ( min(my_exceptions, key=lambda e: e[lock_field] or date.min) if my_exceptions else False ) earliest_global = ( min(global_exceptions, key=lambda e: e[lock_field] or date.min) if global_exceptions else False ) wiz[f"min_{lock_field}_exception_for_me_id"] = earliest_mine wiz[f"min_{lock_field}_exception_for_everyone_id"] = earliest_global wiz[f"{lock_field}_for_me"] = earliest_mine.lock_date if earliest_mine else False wiz[f"{lock_field}_for_everyone"] = earliest_global.lock_date if earliest_global else False def _build_draft_moves_domain(self): """Build a domain to find draft moves that would fall within any of the configured lock periods.""" self.ensure_one() period_domains = [] if self.hard_lock_date: period_domains.append([('date', '<=', self.hard_lock_date)]) if self.fiscalyear_lock_date: period_domains.append([('date', '<=', self.fiscalyear_lock_date)]) if self.sale_lock_date: period_domains.append([ ('date', '<=', self.sale_lock_date), ('journal_id.type', '=', 'sale'), ]) if self.purchase_lock_date: period_domains.append([ ('date', '<=', self.purchase_lock_date), ('journal_id.type', '=', 'purchase'), ]) return [ ('company_id', 'child_of', self.env.company.id), ('state', '=', 'draft'), *expression.OR(period_domains), ] # Keep backward-compatible alias _get_draft_moves_in_locked_period_domain = _build_draft_moves_domain @api.depends('fiscalyear_lock_date', 'tax_lock_date', 'sale_lock_date', 'purchase_lock_date', 'hard_lock_date') def _compute_show_draft_entries_warning(self): """Flag whether any draft journal entries exist in the locked period.""" AccountMove = self.env['account.move'] for wiz in self: has_drafts = bool(AccountMove.search( wiz._build_draft_moves_domain(), limit=1, )) wiz.show_draft_entries_warning = has_drafts def _get_changes_needing_exception(self): """Identify soft lock fields that are being loosened (i.e. the new date is earlier than the current company setting).""" self.ensure_one() company = self.env.company relaxed = {} for fld in SOFT_LOCK_DATE_FIELDS: current_val = company[fld] new_val = self[fld] if current_val and (not new_val or new_val < current_val): relaxed[fld] = new_val return relaxed @api.depends(*SOFT_LOCK_DATE_FIELDS) def _compute_exception_needed(self): for wiz in self: wiz.exception_needed = bool(wiz._get_changes_needing_exception()) @api.depends(*SOFT_LOCK_DATE_FIELDS) def _compute_exception_needed_fields(self): for wiz in self: relaxed_fields = wiz._get_changes_needing_exception() wiz.exception_needed_fields = ','.join(relaxed_fields) # ------------------------------------------------------------------------- # Lock Date Value Preparation # ------------------------------------------------------------------------- def _prepare_lock_date_values(self, exception_vals_list=None): """Return a dict of lock-date fields that have changed and are not covered by a newly created exception.""" self.ensure_one() company = self.env.company # Hard lock date can never decrease if company.hard_lock_date and ( not self.hard_lock_date or self.hard_lock_date < company.hard_lock_date ): raise UserError(_( 'The Hard Lock Date cannot be decreased or removed once set.' )) changed_vals = {} for fld in LOCK_DATE_FIELDS: if self[fld] != company[fld]: changed_vals[fld] = self[fld] # No lock date may be set in the future today = fields.Date.context_today(self) for fld, val in changed_vals.items(): if val and val > today: raise UserError(_( 'A Lock Date cannot be set to a future date.' )) # Exclude fields that are being handled via exception creation if exception_vals_list: for exc_vals in exception_vals_list: for fld in LOCK_DATE_FIELDS: if fld in exc_vals: changed_vals.pop(fld, None) return changed_vals def _prepare_exception_values(self): """Construct a list of dicts suitable for creating account.lock_exception records for any loosened lock dates.""" self.ensure_one() relaxed = self._get_changes_needing_exception() if not relaxed: return False # Relaxing for everyone forever is equivalent to simply updating the date if self.exception_applies_to == 'everyone' and self.exception_duration == 'forever': return False # Validate that scope and duration are set validation_issues = [] if not self.exception_applies_to: validation_issues.append(_('Please select who the exception applies to.')) if not self.exception_duration: validation_issues.append(_('Please select a duration for the exception.')) if validation_issues: raise UserError('\n'.join(validation_issues)) # Build shared exception values shared_vals = { 'company_id': self.env.company.id, } # Determine target user scope_to_user = { 'me': self.env.user.id, 'everyone': False, } shared_vals['user_id'] = scope_to_user[self.exception_applies_to] # Determine expiration duration_map = { '5min': timedelta(minutes=5), '15min': timedelta(minutes=15), '1h': timedelta(hours=1), '24h': timedelta(hours=24), 'forever': False, } delta = duration_map[self.exception_duration] if delta: shared_vals['end_datetime'] = self.env.cr.now() + delta if self.exception_reason: shared_vals['reason'] = self.exception_reason return [ {**shared_vals, fld: val} for fld, val in relaxed.items() ] # ------------------------------------------------------------------------- # Period Date Helpers # ------------------------------------------------------------------------- def _get_current_period_dates(self, lock_date_field): """Determine the start and end of the current period relative to the selected lock date (using the prior lock date or fiscal year start).""" self.ensure_one() existing_lock = self.env.company[lock_date_field] if existing_lock: period_start = existing_lock + timedelta(days=1) else: period_start = date_utils.get_fiscal_year(self[lock_date_field])[0] return period_start, self[lock_date_field] def _create_default_report_external_values(self, lock_date_field): """Hook for generating default report external values when lock dates change. Extended by account reporting modules.""" date_from, date_to = self._get_current_period_dates(lock_date_field) self.env['account.report']._generate_default_external_values( date_from, date_to, lock_date_field == 'tax_lock_date', ) # ------------------------------------------------------------------------- # Core Lock Date Application # ------------------------------------------------------------------------- def _change_lock_date(self, lock_date_values=None): """Apply the given lock date values to the company, generating default report external values as needed.""" self.ensure_one() if lock_date_values is None: lock_date_values = self._prepare_lock_date_values() # Handle tax lock date report externals new_tax_lock = lock_date_values.get('tax_lock_date') if new_tax_lock and new_tax_lock != self.env.company['tax_lock_date']: self._create_default_report_external_values('tax_lock_date') # Handle fiscal year / hard lock date report externals new_fy_lock = lock_date_values.get('fiscalyear_lock_date') new_hard_lock = lock_date_values.get('hard_lock_date') if new_fy_lock or new_hard_lock: candidate_lock, candidate_field = max( [(new_fy_lock, 'fiscalyear_lock_date'), (new_hard_lock, 'hard_lock_date')], key=lambda pair: pair[0] or date.min, ) existing_fy_max = max( self.env.company.fiscalyear_lock_date or date.min, self.env.company.hard_lock_date or date.min, ) if candidate_lock != existing_fy_max: self._create_default_report_external_values(candidate_field) self.env.company.sudo().write(lock_date_values) def change_lock_date(self): """Main action: validate permissions, create exceptions if needed, and apply new lock dates to the company.""" self.ensure_one() if not self.env.user.has_group('account.group_account_manager'): raise UserError(_( 'Only Billing Administrators are allowed to change lock dates!' )) exc_vals_list = self._prepare_exception_values() updated_lock_vals = self._prepare_lock_date_values( exception_vals_list=exc_vals_list, ) if exc_vals_list: self.env['account.lock_exception'].create(exc_vals_list) self._change_lock_date(updated_lock_vals) return {'type': 'ir.actions.act_window_close'} # ------------------------------------------------------------------------- # UI Actions # ------------------------------------------------------------------------- def action_show_draft_moves_in_locked_period(self): """Open a list view showing draft moves within the locked period.""" self.ensure_one() return { 'view_mode': 'list', 'name': _('Draft Entries'), 'res_model': 'account.move', 'type': 'ir.actions.act_window', 'domain': self._build_draft_moves_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_reopen_wizard(self): """Return an action that reopens this wizard in a dialog.""" return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } def _action_revoke_min_exception(self, exception_field): """Revoke a specific exception and refresh the wizard.""" self.ensure_one() target_exception = self[exception_field] if target_exception: target_exception.action_revoke() self._compute_lock_date_exceptions() return self.action_reopen_wizard() # --- Per-field revoke actions (current user) --- def action_revoke_min_sale_lock_date_exception_for_me(self): return self._action_revoke_min_exception('min_sale_lock_date_exception_for_me_id') def action_revoke_min_purchase_lock_date_exception_for_me(self): return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_me_id') def action_revoke_min_tax_lock_date_exception_for_me(self): return self._action_revoke_min_exception('min_tax_lock_date_exception_for_me_id') def action_revoke_min_fiscalyear_lock_date_exception_for_me(self): return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_me_id') # --- Per-field revoke actions (everyone) --- def action_revoke_min_sale_lock_date_exception_for_everyone(self): return self._action_revoke_min_exception('min_sale_lock_date_exception_for_everyone_id') def action_revoke_min_purchase_lock_date_exception_for_everyone(self): return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_everyone_id') def action_revoke_min_tax_lock_date_exception_for_everyone(self): return self._action_revoke_min_exception('min_tax_lock_date_exception_for_everyone_id') def action_revoke_min_fiscalyear_lock_date_exception_for_everyone(self): return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_everyone_id')