# 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'