518 lines
20 KiB
Python
518 lines
20 KiB
Python
# 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')
|