Initial commit
This commit is contained in:
517
Fusion Accounting/wizard/account_change_lock_date.py
Normal file
517
Fusion Accounting/wizard/account_change_lock_date.py
Normal file
@@ -0,0 +1,517 @@
|
||||
# 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')
|
||||
Reference in New Issue
Block a user