Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,621 @@
# Fusion Accounting - Deferred Revenue / Expense Report Handlers
# Computes period-by-period deferral breakdowns, generates closing entries
import calendar
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import models, fields, _, api, Command
from odoo.exceptions import UserError
from odoo.tools import groupby, SQL
from odoo.addons.fusion_accounting.models.account_move import DEFERRED_DATE_MIN, DEFERRED_DATE_MAX
class FusionDeferredReportHandler(models.AbstractModel):
"""Base handler for deferred expense / revenue reports. Provides
shared domain construction, SQL queries, grouping logic, and
deferral-entry generation. Concrete sub-handlers set the report
type via ``_get_deferred_report_type``."""
_name = 'account.deferred.report.handler'
_inherit = 'account.report.custom.handler'
_description = 'Deferred Expense Report Custom Handler'
def _get_deferred_report_type(self):
raise NotImplementedError(
"Subclasses must return either 'expense' or 'revenue'."
)
# =====================================================================
# DOMAIN & QUERY HELPERS
# =====================================================================
def _get_domain(self, report, options, filter_already_generated=False, filter_not_started=False):
"""Build the search domain for deferred journal items within
the selected report period."""
base_domain = report._get_options_domain(options, "from_beginning")
if self._get_deferred_report_type() == 'expense':
acct_types = ('expense', 'expense_depreciation', 'expense_direct_cost')
else:
acct_types = ('income', 'income_other')
base_domain += [
('account_id.account_type', 'in', acct_types),
('deferred_start_date', '!=', False),
('deferred_end_date', '!=', False),
('deferred_end_date', '>=', options['date']['date_from']),
('move_id.date', '<=', options['date']['date_to']),
]
# Exclude lines that fall entirely within the period
base_domain += [
'!', '&', '&', '&', '&', '&',
('deferred_start_date', '>=', options['date']['date_from']),
('deferred_start_date', '<=', options['date']['date_to']),
('deferred_end_date', '>=', options['date']['date_from']),
('deferred_end_date', '<=', options['date']['date_to']),
('move_id.date', '>=', options['date']['date_from']),
('move_id.date', '<=', options['date']['date_to']),
]
if filter_already_generated:
base_domain += [
('deferred_end_date', '>=', options['date']['date_from']),
'!',
'&',
('move_id.deferred_move_ids.date', '=', options['date']['date_to']),
('move_id.deferred_move_ids.state', '=', 'posted'),
]
if filter_not_started:
base_domain += [('deferred_start_date', '>', options['date']['date_to'])]
return base_domain
@api.model
def _get_select(self):
"""Column expressions for the deferred-lines query."""
acct_name_expr = self.env['account.account']._field_to_sql(
'account_move_line__account_id', 'name',
)
return [
SQL("account_move_line.id AS line_id"),
SQL("account_move_line.account_id AS account_id"),
SQL("account_move_line.partner_id AS partner_id"),
SQL("account_move_line.product_id AS product_id"),
SQL("account_move_line__product_template_id.categ_id AS product_category_id"),
SQL("account_move_line.name AS line_name"),
SQL("account_move_line.deferred_start_date AS deferred_start_date"),
SQL("account_move_line.deferred_end_date AS deferred_end_date"),
SQL("account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days"),
SQL("account_move_line.balance AS balance"),
SQL("account_move_line.analytic_distribution AS analytic_distribution"),
SQL("account_move_line__move_id.id as move_id"),
SQL("account_move_line__move_id.name AS move_name"),
SQL("%s AS account_name", acct_name_expr),
]
def _get_lines(self, report, options, filter_already_generated=False):
"""Execute the deferred-lines query and return raw dicts."""
search_domain = self._get_domain(report, options, filter_already_generated)
qry = report._get_report_query(options, domain=search_domain, date_scope='from_beginning')
cols = SQL(', ').join(self._get_select())
full_query = SQL(
"""
SELECT %(cols)s
FROM %(from_clause)s
LEFT JOIN product_product AS account_move_line__product_id
ON account_move_line.product_id = account_move_line__product_id.id
LEFT JOIN product_template AS account_move_line__product_template_id
ON account_move_line__product_id.product_tmpl_id = account_move_line__product_template_id.id
WHERE %(where_clause)s
ORDER BY account_move_line.deferred_start_date, account_move_line.id
""",
cols=cols,
from_clause=qry.from_clause,
where_clause=qry.where_clause,
)
self.env.cr.execute(full_query)
return self.env.cr.dictfetchall()
# =====================================================================
# GROUPING HELPERS
# =====================================================================
@api.model
def _get_grouping_fields_deferred_lines(self, filter_already_generated=False, grouping_field='account_id'):
return (grouping_field,)
@api.model
def _group_by_deferred_fields(self, line, filter_already_generated=False, grouping_field='account_id'):
return tuple(
line[k] for k in self._get_grouping_fields_deferred_lines(filter_already_generated, grouping_field)
)
@api.model
def _get_grouping_fields_deferral_lines(self):
return ()
@api.model
def _group_by_deferral_fields(self, line):
return tuple(line[k] for k in self._get_grouping_fields_deferral_lines())
@api.model
def _group_deferred_amounts_by_grouping_field(
self, deferred_amounts_by_line, periods, is_reverse,
filter_already_generated=False, grouping_field='account_id',
):
"""Group deferred amounts per grouping field and compute period
totals. Returns ``(per_key_totals, aggregate_totals)``."""
grouped_iter = groupby(
deferred_amounts_by_line,
key=lambda row: self._group_by_deferred_fields(row, filter_already_generated, grouping_field),
)
per_key = {}
aggregate = {p: 0 for p in periods + ['totals_aggregated']}
multiplier = 1 if is_reverse else -1
for key, key_lines in grouped_iter:
key_lines = list(key_lines)
key_totals = self._get_current_key_totals_dict(key_lines, multiplier)
aggregate['totals_aggregated'] += key_totals['amount_total']
for period in periods:
period_val = multiplier * sum(ln[period] for ln in key_lines)
key_totals[period] = period_val
aggregate[period] += self.env.company.currency_id.round(period_val)
per_key[key] = key_totals
return per_key, aggregate
@api.model
def _get_current_key_totals_dict(self, key_lines, multiplier):
return {
'account_id': key_lines[0]['account_id'],
'product_id': key_lines[0]['product_id'],
'product_category_id': key_lines[0]['product_category_id'],
'amount_total': multiplier * sum(ln['balance'] for ln in key_lines),
'move_ids': {ln['move_id'] for ln in key_lines},
}
# =====================================================================
# REPORT DISPLAY
# =====================================================================
def _get_custom_display_config(self):
return {
'templates': {
'AccountReportFilters': 'fusion_accounting.DeferredFilters',
},
}
def _custom_options_initializer(self, report, options, previous_options):
super()._custom_options_initializer(report, options, previous_options=previous_options)
per_col_group = report._split_options_per_column_group(options)
for col_dict in options['columns']:
col_opts = per_col_group[col_dict['column_group_key']]
col_dict['name'] = col_opts['date']['string']
col_dict['date_from'] = col_opts['date']['date_from']
col_dict['date_to'] = col_opts['date']['date_to']
options['columns'] = list(reversed(options['columns']))
total_col = [{
**options['columns'][0],
'name': _('Total'),
'expression_label': 'total',
'date_from': DEFERRED_DATE_MIN,
'date_to': DEFERRED_DATE_MAX,
}]
not_started_col = [{
**options['columns'][0],
'name': _('Not Started'),
'expression_label': 'not_started',
'date_from': options['columns'][-1]['date_to'],
'date_to': DEFERRED_DATE_MAX,
}]
before_col = [{
**options['columns'][0],
'name': _('Before'),
'expression_label': 'before',
'date_from': DEFERRED_DATE_MIN,
'date_to': options['columns'][0]['date_from'],
}]
later_col = [{
**options['columns'][0],
'name': _('Later'),
'expression_label': 'later',
'date_from': options['columns'][-1]['date_to'],
'date_to': DEFERRED_DATE_MAX,
}]
options['columns'] = total_col + not_started_col + before_col + options['columns'] + later_col
options['column_headers'] = []
options['deferred_report_type'] = self._get_deferred_report_type()
options['deferred_grouping_field'] = previous_options.get('deferred_grouping_field') or 'account_id'
co = self.env.company
report_type = self._get_deferred_report_type()
is_manual = (
(report_type == 'expense' and co.generate_deferred_expense_entries_method == 'manual')
or (report_type == 'revenue' and co.generate_deferred_revenue_entries_method == 'manual')
)
if is_manual:
options['buttons'].append({
'name': _('Generate entry'),
'action': 'action_generate_entry',
'sequence': 80,
'always_show': True,
})
def action_audit_cell(self, options, params):
"""Open a list of the invoices/bills and deferral entries
that underlie the clicked cell in the deferred report."""
report = self.env['account.report'].browse(options['report_id'])
col_data = next(
(c for c in options['columns']
if c['column_group_key'] == params.get('column_group_key')
and c['expression_label'] == params.get('expression_label')),
None,
)
if not col_data:
return
col_from = fields.Date.to_date(col_data['date_from'])
col_to = fields.Date.to_date(col_data['date_to'])
rpt_from = fields.Date.to_date(options['date']['date_from'])
rpt_to = fields.Date.to_date(options['date']['date_to'])
if col_data['expression_label'] in ('not_started', 'later'):
col_from = rpt_to + relativedelta(days=1)
if col_data['expression_label'] == 'before':
col_to = rpt_from - relativedelta(days=1)
_grp_model, grp_record_id = report._get_model_info_from_id(
params.get('calling_line_dict_id'),
)
source_domain = self._get_domain(
report, options,
filter_not_started=(col_data['expression_label'] == 'not_started'),
)
if grp_record_id:
source_domain.append(
(options['deferred_grouping_field'], '=', grp_record_id)
)
source_moves = self.env['account.move.line'].search(source_domain).move_id
visible_line_ids = source_moves.line_ids.ids
if col_data['expression_label'] != 'total':
visible_line_ids += source_moves.deferred_move_ids.line_ids.ids
return {
'type': 'ir.actions.act_window',
'name': _('Deferred Entries'),
'res_model': 'account.move.line',
'domain': [('id', 'in', visible_line_ids)],
'views': [(self.env.ref('fusion_accounting.view_deferred_entries_tree').id, 'list')],
'context': {
'search_default_pl_accounts': True,
f'search_default_{options["deferred_grouping_field"]}': grp_record_id,
'date_from': col_from,
'date_to': col_to,
'search_default_date_between': True,
'expand': True,
},
}
def _caret_options_initializer(self):
return {
'deferred_caret': [
{'name': _("Journal Items"), 'action': 'open_journal_items'},
],
}
def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
rpt_type = self._get_deferred_report_type()
co = self.env.company
is_manual_and_generated = (
(rpt_type == 'expense' and co.generate_deferred_expense_entries_method == 'manual'
or rpt_type == 'revenue' and co.generate_deferred_revenue_entries_method == 'manual')
and self.env['account.move'].search_count(
report._get_generated_deferral_entries_domain(options),
)
)
if is_manual_and_generated:
warnings['fusion_accounting.deferred_report_warning_already_posted'] = {
'alert_type': 'warning',
}
def open_journal_items(self, options, params):
report = self.env['account.report'].browse(options['report_id'])
rec_model, rec_id = report._get_model_info_from_id(params.get('line_id'))
item_domain = self._get_domain(report, options)
if rec_model == 'account.account' and rec_id:
item_domain += [('account_id', '=', rec_id)]
elif rec_model == 'product.product' and rec_id:
item_domain += [('product_id', '=', rec_id)]
elif rec_model == 'product.category' and rec_id:
item_domain += [('product_category_id', '=', rec_id)]
return {
'type': 'ir.actions.act_window',
'name': _("Deferred Entries"),
'res_model': 'account.move.line',
'domain': item_domain,
'views': [(self.env.ref('fusion_accounting.view_deferred_entries_tree').id, 'list')],
'context': {
'search_default_group_by_move': True,
'expand': True,
},
}
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
"""Build the report lines by computing deferred amounts per
period and grouping field."""
def _format_columns(row_totals):
return [
{
**report._build_column_dict(
row_totals[(
fields.Date.to_date(col['date_from']),
fields.Date.to_date(col['date_to']),
col['expression_label'],
)],
col,
options=options,
currency=self.env.company.currency_id,
),
'auditable': True,
}
for col in options['columns']
]
raw_lines = self._get_lines(report, options)
col_periods = [
(
fields.Date.from_string(c['date_from']),
fields.Date.from_string(c['date_to']),
c['expression_label'],
)
for c in options['columns']
]
per_line_amounts = self.env['account.move']._get_deferred_amounts_by_line(
raw_lines, col_periods, self._get_deferred_report_type(),
)
per_key, totals = self._group_deferred_amounts_by_grouping_field(
deferred_amounts_by_line=per_line_amounts,
periods=col_periods,
is_reverse=(self._get_deferred_report_type() == 'expense'),
filter_already_generated=False,
grouping_field=options['deferred_grouping_field'],
)
output_lines = []
grp_model_name = self.env['account.move.line'][options['deferred_grouping_field']]._name
for key_totals in per_key.values():
grp_record = self.env[grp_model_name].browse(
key_totals[options['deferred_grouping_field']]
)
field_desc = self.env['account.move.line'][options['deferred_grouping_field']]._description
if options['deferred_grouping_field'] == 'product_id':
field_desc = _("Product")
display_label = grp_record.display_name or _("(No %s)", field_desc)
output_lines.append((0, {
'id': report._get_generic_line_id(grp_model_name, grp_record.id),
'name': display_label,
'caret_options': 'deferred_caret',
'level': 1,
'columns': _format_columns(key_totals),
}))
if per_key:
output_lines.append((0, {
'id': report._get_generic_line_id(None, None, markup='total'),
'name': 'Total',
'level': 1,
'columns': _format_columns(totals),
}))
return output_lines
# =====================================================================
# ENTRY GENERATION
# =====================================================================
def action_generate_entry(self, options):
new_moves = self._generate_deferral_entry(options)
return {
'name': _('Deferred Entries'),
'type': 'ir.actions.act_window',
'views': [(False, "list"), (False, "form")],
'domain': [('id', 'in', new_moves.ids)],
'res_model': 'account.move',
'context': {
'search_default_group_by_move': True,
'expand': True,
},
'target': 'current',
}
def _generate_deferral_entry(self, options):
"""Create the deferral move and its reversal for the selected period."""
rpt_type = self._get_deferred_report_type()
co = self.env.company
target_journal = (
co.deferred_expense_journal_id if rpt_type == "expense"
else co.deferred_revenue_journal_id
)
if not target_journal:
raise UserError(_("Please configure the deferred journal in accounting settings."))
period_start = fields.Date.to_date(DEFERRED_DATE_MIN)
period_end = fields.Date.from_string(options['date']['date_to'])
last_day = calendar.monthrange(period_end.year, period_end.month)[1]
if period_end.day != last_day:
raise UserError(
_("Entries can only be generated for periods ending on the last day of a month.")
)
if co._get_violated_lock_dates(period_end, False, target_journal):
raise UserError(_("Entries cannot be generated for a locked period."))
options['all_entries'] = False
report = self.env["account.report"].browse(options["report_id"])
self.env['account.move.line'].flush_model()
raw_lines = self._get_lines(report, options, filter_already_generated=True)
period_info = self.env['account.report']._get_dates_period(
period_start, period_end, 'range', period_type='month',
)
entry_ref = _("Grouped Deferral Entry of %s", period_info['string'])
reversal_ref = _("Reversal of Grouped Deferral Entry of %s", period_info['string'])
deferral_account = (
co.deferred_expense_account_id if rpt_type == 'expense'
else co.deferred_revenue_account_id
)
move_cmds, orig_move_ids = self._get_deferred_lines(
raw_lines, deferral_account,
(period_start, period_end, 'current'),
rpt_type == 'expense', entry_ref,
)
if not move_cmds:
raise UserError(_("No entry to generate."))
deferral_move = self.env['account.move'].with_context(
skip_account_deprecation_check=True,
).create({
'move_type': 'entry',
'deferred_original_move_ids': [Command.set(orig_move_ids)],
'journal_id': target_journal.id,
'date': period_end,
'auto_post': 'at_date',
'ref': entry_ref,
})
deferral_move.write({'line_ids': move_cmds})
reversal = deferral_move._reverse_moves()
reversal.write({
'date': deferral_move.date + relativedelta(days=1),
'ref': reversal_ref,
})
reversal.line_ids.name = reversal_ref
combined = deferral_move + reversal
self.env.cr.execute_values("""
INSERT INTO account_move_deferred_rel(original_move_id, deferred_move_id)
VALUES %s
ON CONFLICT DO NOTHING
""", [
(orig_id, dm.id)
for orig_id in orig_move_ids
for dm in combined
])
combined._post(soft=True)
return combined
@api.model
def _get_deferred_lines(self, raw_lines, deferral_account, period, is_reverse, label):
"""Compute the journal-item commands for a deferral entry and
return ``(line_commands, original_move_ids)``."""
if not deferral_account:
raise UserError(_("Please configure the deferred accounts in accounting settings."))
per_line_amounts = self.env['account.move']._get_deferred_amounts_by_line(
raw_lines, [period], is_reverse,
)
per_key, agg_totals = self._group_deferred_amounts_by_grouping_field(
per_line_amounts, [period], is_reverse, filter_already_generated=True,
)
if agg_totals['totals_aggregated'] == agg_totals[period]:
return [], set()
# Build per-key analytic distributions
dist_per_key = defaultdict(lambda: defaultdict(float))
deferral_dist = defaultdict(lambda: defaultdict(float))
for ln in raw_lines:
if not ln['analytic_distribution']:
continue
total_ratio = (
(ln['balance'] / agg_totals['totals_aggregated'])
if agg_totals['totals_aggregated'] else 0
)
key_data = per_key.get(self._group_by_deferred_fields(ln, True))
key_ratio = (
(ln['balance'] / key_data['amount_total'])
if key_data and key_data['amount_total'] else 0
)
for analytic_id, pct in ln['analytic_distribution'].items():
dist_per_key[self._group_by_deferred_fields(ln, True)][analytic_id] += pct * key_ratio
deferral_dist[self._group_by_deferral_fields(ln)][analytic_id] += pct * total_ratio
currency = self.env.company.currency_id
balance_remainder = 0
entry_lines = []
source_move_ids = set()
sign = 1 if is_reverse else -1
for key, kv in per_key.items():
for amt in (-kv['amount_total'], kv[period]):
if amt != 0 and kv[period] != kv['amount_total']:
source_move_ids |= kv['move_ids']
adjusted_balance = currency.round(sign * amt)
entry_lines.append(Command.create(
self.env['account.move.line']._get_deferred_lines_values(
account_id=kv['account_id'],
balance=adjusted_balance,
ref=label,
analytic_distribution=dist_per_key[key] or False,
line=kv,
)
))
balance_remainder += adjusted_balance
# Group deferral-account lines
grouped_values = {
k: list(v)
for k, v in groupby(per_key.values(), key=self._group_by_deferral_fields)
}
deferral_lines = []
for key, key_items in grouped_values.items():
key_balance = 0
for item in key_items:
if item[period] != item['amount_total']:
key_balance += currency.round(
sign * (item['amount_total'] - item[period])
)
deferral_lines.append(Command.create(
self.env['account.move.line']._get_deferred_lines_values(
account_id=deferral_account.id,
balance=key_balance,
ref=label,
analytic_distribution=deferral_dist[key] or False,
line=key_items[0],
)
))
balance_remainder += key_balance
if not currency.is_zero(balance_remainder):
deferral_lines.append(Command.create({
'account_id': deferral_account.id,
'balance': -balance_remainder,
'name': label,
}))
return entry_lines + deferral_lines, source_move_ids
class FusionDeferredExpenseHandler(models.AbstractModel):
_name = 'account.deferred.expense.report.handler'
_inherit = 'account.deferred.report.handler'
_description = 'Deferred Expense Custom Handler'
def _get_deferred_report_type(self):
return 'expense'
class FusionDeferredRevenueHandler(models.AbstractModel):
_name = 'account.deferred.revenue.report.handler'
_inherit = 'account.deferred.report.handler'
_description = 'Deferred Revenue Custom Handler'
def _get_deferred_report_type(self):
return 'revenue'