import csv import io import json from collections import defaultdict from itertools import groupby from textwrap import shorten from odoo import models, fields, _ from odoo.tools import float_repr, SQL from odoo.exceptions import UserError class AccountGeneralLedgerReportHandler(models.AbstractModel): _name = 'account.general.ledger.report.handler' _inherit = ['account.report.custom.handler'] _description = 'General Ledger Custom Handler' def _custom_options_initializer(self, report, options, previous_options): options['buttons'].append({ 'name': _("CSV"), 'sequence': 50, 'action': 'export_file', 'action_param': 'generate_csv_export', 'file_export_type': _('CSV'), }) super()._custom_options_initializer(report, options, previous_options=previous_options) # Remove multi-currency columns if needed if self.env.user.has_group('base.group_multi_currency'): options['multi_currency'] = True else: options['columns'] = [ column for column in options['columns'] if column['expression_label'] != 'amount_currency' ] # Automatically unfold the report when printing it, unless some specific lines have been unfolded options['unfold_all'] = (options['export_mode'] == 'print' and not options.get('unfolded_lines')) or options['unfold_all'] if options.get('force_not_unfold_all'): options['unfold_all'] = False options['custom_display_config'] = { 'templates': { 'AccountReportLineName': 'account_reports.GeneralLedgerLineName', }, } def _caret_options_initializer(self): default_caret = self.env['account.report']._caret_options_initializer_default() return { **default_caret, 'id_with_accumulated_balance_caret': [ {'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form_custom_id_groupby', 'action_param': 'move_id'}, ], 'undistributed_profits_losses': [ {'name': _("Journal Items"), 'action': 'open_unallocated_items_journal_items'}, ], } def open_unallocated_items_journal_items(self, options, params): report = self.env['account.report'].browse(options['report_id']) return report.open_unallocated_items_journal_items(options, params) def caret_option_open_record_form_custom_id_groupby(self, options, params): report = self.env['account.report'].browse(options['report_id']) _model, aml_key = report._get_model_info_from_id(params['line_id']) record_id = json.loads(aml_key)[1] record = self.env['account.move.line'].browse(record_id) target_record = record[params['action_param']] if 'action_param' in params else record view_id = report._resolve_caret_option_view(target_record) action = { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'views': [(view_id, 'form')], # view_id will be False in case the default view is needed 'res_model': target_record._name, 'res_id': target_record.id, 'context': self.env.context, } if view_id is not None: action['view_id'] = view_id return action def _get_custom_groupby_map(self): def custom_label_builder(grouping_keys): """ Batch label builder used to rename balance lines. """ keys_names_in_sequence = {} ids_to_browse = [] aml_keys = [] for grouping_key in grouping_keys: if 'balance_line' in grouping_key: keys_names_in_sequence[grouping_key] = _("Initial Balance") else: combined_key = json.loads(grouping_key) ids_to_browse.append(combined_key[1]) aml_keys.append(grouping_key) records_unsorted = self.env['account.move.line'].browse(ids_to_browse) records_unsorted.fetch(['display_name']) for record, aml_key in zip(records_unsorted, aml_keys): keys_names_in_sequence[aml_key] = shorten(record.display_name, width=200) return keys_names_in_sequence def domain_builder(grouping_key): if 'balance_line' in grouping_key: return [] grouping_key_array = json.loads(grouping_key) return [('id', '=', grouping_key_array[1]), ('date', '=', grouping_key_array[0])] return { 'id_with_accumulated_balance': { 'model': None, 'domain_builder': domain_builder, 'caret_builder': lambda grouping_key: None if 'balance_line' in grouping_key else 'id_with_accumulated_balance_caret', 'label_builder': custom_label_builder, }, } def _report_custom_engine_general_ledger(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): def get_grouping_key(row, groupby): if groupby == 'id_with_accumulated_balance': if not row['id']: return f"balance_line_{row['account_id']}" else: return json.dumps([fields.Date.to_string(row['date']), row['id']]) return row[groupby] if groupby else None query = self._get_query(options, current_groupby, offset=offset, limit=limit) rows_by_key = defaultdict(lambda: { 'date': None, 'invoice_date': None, 'partner_name': None, 'amount_currency': None, 'currency_id': self.env.company.currency_id.id, 'debit': 0, 'credit': 0, 'balance': 0, 'has_sublines': True, }) for row in self.env.execute_query_dict(query): aml_key = get_grouping_key(row, current_groupby) if aml_key not in rows_by_key: rows_by_key[aml_key].update({ 'debit': row['debit'], 'credit': row['credit'], 'balance': row['balance'], }) if current_groupby == 'id_with_accumulated_balance': rows_by_key[aml_key]['has_sublines'] = False rows_by_key[aml_key]['account_id'] = row['account_id'] # Needed for batching if 'balance_line' not in aml_key: rows_by_key[aml_key]['date'] = row['date'] rows_by_key[aml_key]['partner_name'] = row['partner_name'] rows_by_key[aml_key]['line_name'] = row['line_name'] rows_by_key[aml_key]['account_code'] = row['account_code'] rows_by_key[aml_key]['account_name'] = row['account_name'] rows_by_key[aml_key]['move_name'] = row['move_name'] if any(column['expression_label'] == 'invoice_date' for column in options['columns']): rows_by_key[aml_key]['invoice_date'] = row['invoice_date'] if row['currency_id'] != self.env.company.currency_id.id: rows_by_key[aml_key]['amount_currency'] = row['amount_currency'] rows_by_key[aml_key]['currency_id'] = row['currency_id'] elif current_groupby == 'account_id': rows_by_key[aml_key]['has_sublines'] = True if row.get('currency_id'): rows_by_key[aml_key]['amount_currency'] = row['amount_currency'] rows_by_key[aml_key]['currency_id'] = row['currency_id'] else: rows_by_key[aml_key]['debit'] += row['debit'] rows_by_key[aml_key]['credit'] += row['credit'] rows_by_key[aml_key]['balance'] += row['balance'] if row.get('currency_id'): rows_by_key[aml_key]['currency_id'] += row['currency_id'] if not current_groupby: return rows_by_key[None] # None is the key for total line as there is no groupby return [(key, entry) for key, entry in rows_by_key.items()] def _get_query(self, options, current_groupby, order_by_account=False, offset=0, limit=None): report = self.env['account.report'].browse(options['report_id']) options_date_from = fields.Date.from_string(options['date']['date_from']) current_fiscalyear_date_from = self.env.company.compute_fiscalyear_dates(options_date_from)['date_from'] # We want to exclude move lines from expense and income accounts before the fiscal year for every groupby under account_id additional_domain = [ '|', ('account_id.include_initial_balance', '=', True), ('date', '>=', current_fiscalyear_date_from), ] report_query = report._get_report_query(options, 'from_beginning', additional_domain) if options.get('export_mode') == 'print' and options.get('filter_search_bar') and current_groupby not in ('id_with_accumulated_balance', 'id'): search_bar_sql = SQL( """ AND account_move_line.account_id = ANY(%(search_bar_account_query)s) """, search_bar_account_query=self.env['account.account']._search([ ('display_name', 'ilike', options.get('filter_search_bar')), *self.env['account.account']._check_company_domain(self.env['account.report'].get_report_company_ids(options)), ]).select(SQL.identifier('id')) ) else: search_bar_sql = SQL() additional_select = SQL("") groupby = [] if current_groupby == 'id_with_accumulated_balance': account_code_select = self.env['account.account']._field_to_sql('account_move_line__account_id', 'code', report_query) account_name_select = self.env['account.account']._field_to_sql('account_move_line__account_id', 'name') invoice_date_select = SQL( """ MIN( CASE WHEN account_move_line.date >= %(date)s THEN account_move_line.invoice_date ELSE NULL END ) AS invoice_date, """, date=options_date_from, ) if any(column['expression_label'] == 'invoice_date' for column in options['columns']) else SQL("") additional_select = SQL(""" CASE WHEN account_move_line.date >= %(date)s THEN account_move_line.id ELSE NULL END AS id, CASE WHEN account_move_line.date >= %(date)s THEN account_move_line.date ELSE NULL END AS date, %(invoice_date_select)s MIN(move.name) AS move_name, CASE WHEN MIN(account_move_line.currency_id) = MAX(account_move_line.currency_id) THEN SUM(account_move_line.amount_currency) ELSE NULL END AS amount_currency, MIN(partner.name) AS partner_name, CASE WHEN MIN(account_move_line.currency_id) = MAX(account_move_line.currency_id) THEN MIN(account_move_line.currency_id) ELSE NULL END AS currency_id, MIN(account_move_line__account_id.id) AS account_id, MIN(account_move_line.name) AS line_name, MIN(%(account_name_select)s) AS account_name, MIN(%(account_code_select)s) AS account_code, """, date=options_date_from, invoice_date_select=invoice_date_select, account_name_select=account_name_select, account_code_select=account_code_select, ) groupby = [SQL("1"), SQL("2"), SQL("account_move_line.account_id")] elif current_groupby == 'account_id': additional_select = SQL(""" account_move_line__account_id.id AS account_id, account_move_line__account_id.account_type AS account_type, SUM(account_move_line.amount_currency) AS amount_currency, account_move_line__account_id.currency_id AS currency_id, """) groupby = [SQL("account_move_line__account_id.id"), SQL("account_move_line__account_id.currency_id")] elif current_groupby: groupby_field_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, report_query) additional_select = SQL("%s AS %s,", groupby_field_sql, SQL.identifier(current_groupby)) groupby = [groupby_field_sql] report_query.left_join('account_move_line', 'account_id', 'account_account', 'id', 'account_id') if current_groupby == 'account_id' or order_by_account: order_clause = [self.env['account.account']._order_to_sql(self.env['account.account']._order, report_query, 'account_move_line__account_id')] groupby = order_clause + groupby else: order_clause = [] if current_groupby == 'id_with_accumulated_balance': order_clause.append(SQL("2 NULLS FIRST, move_name, 1 NULLS FIRST")) query = SQL( """ SELECT %(additional_select)s COALESCE(SUM(%(select_debit)s), 0.0) AS debit, COALESCE(SUM(%(select_credit)s), 0.0) AS credit, COALESCE(SUM(%(select_balance)s), 0.0) AS balance FROM %(from_clause)s LEFT JOIN res_partner partner ON partner.id = account_move_line.partner_id JOIN account_move move ON move.id = account_move_line.move_id %(currency_table_join)s WHERE %(where_clause)s %(search_bar_sql)s %(additional_groupby)s %(orderby_clause)s %(offset_clause)s LIMIT %(limit)s """, additional_select=additional_select, select_balance=report._currency_table_apply_rate(SQL("account_move_line.balance")), select_debit=report._currency_table_apply_rate(SQL("account_move_line.debit")), select_credit=report._currency_table_apply_rate(SQL("account_move_line.credit")), from_clause=report_query.from_clause, currency_table_join=report._currency_table_aml_join(options), where_clause=report_query.where_clause, search_bar_sql=search_bar_sql, additional_groupby=SQL("GROUP BY %s", SQL(",").join(groupby)) if groupby else SQL(), orderby_clause=SQL("ORDER BY %s", SQL(",").join(order_clause)) if order_clause else SQL(), offset_clause=SQL("OFFSET %s", offset) if offset else SQL(), limit=limit ) return query def _report_expand_unfoldable_line_with_groupby(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): """ Shadows the function from account_report. This allows us to use progress efficiently to compute the accumulated balance with partial expand capabilities """ report = self.env['account.report'].browse(options['report_id']) result = report._report_expand_unfoldable_line_with_groupby(line_dict_id, groupby, options, progress, offset, unfold_all_batch_data) if groupby != 'id_with_accumulated_balance': return result colname_to_idx = defaultdict(dict) for idx, col in enumerate(options.get('columns', [])): colname_to_idx[col['column_group_key']][col['expression_label']] = idx if options['export_mode'] is None: limit_to_load = report.load_more_limit or None else: limit_to_load = None offset = 0 processed_lines = result['lines'] has_balance_line = False col_group_keys = options['column_groups'] accumulated_balance_by_colgroup = progress.get('accumulated_balance_by_colgroup', { col_group_key: 0.0 for col_group_key in col_group_keys }) for col_group_key in col_group_keys: if 'balance' not in colname_to_idx[col_group_key]: continue for line in processed_lines: line_balance = line['columns'][colname_to_idx[col_group_key]['balance']]['no_format'] accumulated_balance_by_colgroup[col_group_key] += line_balance if line['name'] == 'balance_line': has_balance_line = True line['name'] = _("Initial Balance") else: line['columns'][colname_to_idx[col_group_key]['balance']] = report._build_column_dict(accumulated_balance_by_colgroup[col_group_key], line['columns'][colname_to_idx[col_group_key]['balance']], options) return { **result, 'lines': processed_lines, 'offset_increment': limit_to_load - 1 if has_balance_line and limit_to_load else len(processed_lines), 'progress': { **progress, 'accumulated_balance_by_colgroup': accumulated_balance_by_colgroup, }, } def _get_fiscalyear_start_date(self, options): options_date_from = fields.Date.to_date(options['date']['date_from']) return self.env.company.compute_fiscalyear_dates(options_date_from)['date_from'] def _adjust_total_with_unaffected_earnings(self, total_line_columns, unaffected_earning_values): for col in total_line_columns: if col and col['expression_label'] in unaffected_earning_values: col['no_format'] += unaffected_earning_values[col['expression_label']] col['is_zero'] = not bool(col['no_format']) return total_line_columns def _custom_line_postprocessor(self, report, options, lines): """ This post processor move the total line below the report as it should always be under in the general ledger """ general_ledger_custom_engine_line = self.env.ref('account_reports.general_ledger_custom_engine_line') processed_lines = [] main_line_dict = None account_move_lines = [] unaffected_earning_values = defaultdict(float) # Get the unaffected earning lines if the whole report has been generated # (e.g., not when loading more lines in a group) if report._parse_line_id(lines[0]['id'])[-1] == ('', 'account.report.line', report.line_ids[0].id): unaffected_earning_lines = report._get_unallocated_earnings_lines(options, 'from_beginning') else: unaffected_earning_lines = [] for line in lines + unaffected_earning_lines: markup, model, res_id = report._parse_line_id(line['id'])[-1] if model == 'account.report.line' and res_id == general_ledger_custom_engine_line.id: main_line_dict = line elif markup == 'undistributed_profits_losses': for column in line['columns']: # Swap no_format from 0.0 to None for unaffected lines, to match the other lines if column['expression_label'] == 'amount_currency' and column['is_zero']: column['no_format'] = None elif column['figure_type'] == 'monetary': unaffected_earning_values[column['expression_label']] += column['no_format'] else: processed_lines.append(line) if ( model is None and markup == {'groupby': 'id_with_accumulated_balance'} and not res_id.startswith('balance_line_') and options.get('export_mode') != 'file' ): line['chatter'] = {'id': json.loads(res_id)[1]} account_move_lines.append(line) if account_move_lines: line_ids = (l['chatter']['id'] for l in account_move_lines) account_moves = { line['id']: line['move_id'][0] for line in self.env['account.move.line'].browse(line_ids).read(['id', 'move_id']) } for line in account_move_lines: line['chatter']['id'] = account_moves[line['chatter']['id']] line['chatter']['model'] = 'account.move' if self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections'): if unaffected_earning_lines: total_line = processed_lines.pop(-1) total_line['columns'] = self._adjust_total_with_unaffected_earnings(total_line['columns'], unaffected_earning_values) processed_lines.extend(unaffected_earning_lines) processed_lines.append(total_line) return processed_lines processed_lines.extend(unaffected_earning_lines) if main_line_dict: processed_lines.append({ 'id': report._get_generic_line_id(None, None, 'total'), 'name': _("Total General Ledger"), 'columns': self._adjust_total_with_unaffected_earnings(main_line_dict['columns'], unaffected_earning_values), 'level': 1 }) return processed_lines def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): """ Generate the custom engine's results for each full-sub-groupby-key that would be created when doing an unfold-all on the report. """ results = {} # In the form {full_sub_groupby_key: all_column_group_expression_totals for this groupby computation} for line_to_expand in lines_to_expand_by_function.get('_report_expand_unfoldable_line_with_groupby', []): report_line_id = report._get_res_id_from_line_id(line_to_expand['id'], 'account.report.line') report_line = self.env['account.report.line'].browse(report_line_id) expressions = report_line.expression_ids.filtered( lambda x: x.engine == 'custom' and x.formula == '_report_custom_engine_general_ledger' ) if not expressions: continue for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): for date_scope, expressions_by_date_scope in groupby(expressions, lambda e: e.date_scope): expressions_by_date_scope = list(expressions_by_date_scope) # Get the custom engine results for the given groupby level. engine_account_lines = self._report_custom_engine_general_ledger(expressions_by_date_scope, column_group_options, date_scope, 'account_id', 'id_with_accumulated_balance') account_expression_totals = results.setdefault(f"[{report_line_id}]=>account_id", {})\ .setdefault(column_group_key, {expression: {'value': [], 'sublines_info': set()} for expression in expressions_by_date_scope}) for account_id, engine_account_result_dict in engine_account_lines: for expression in expressions_by_date_scope: account_expression_totals[expression]['value'].append( (account_id, engine_account_result_dict[expression.subformula]) ) if engine_account_result_dict['has_sublines']: account_expression_totals[expression]['sublines_info'].add(account_id) engine_aml_lines = self._report_custom_engine_general_ledger(expressions_by_date_scope, column_group_options, date_scope, 'id_with_accumulated_balance', None) aml_data_by_account = {} for grouping_key, engine_result_dict in engine_aml_lines: engine_result_dict['grouping_key'] = grouping_key aml_data_by_account.setdefault(engine_result_dict['account_id'], []).append(engine_result_dict) for account_id, engine_result_list in aml_data_by_account.items(): account_aml_expression_totals = results.setdefault(f"[{report_line_id}]account_id:{account_id}=>id_with_accumulated_balance", {})\ .setdefault(column_group_key, {expression: {'value': [], 'sublines_info': set()} for expression in expressions_by_date_scope}) for engine_result_dict in engine_result_list: for expression in expressions_by_date_scope: account_aml_expression_totals[expression]['value'].append( (engine_result_dict['grouping_key'], engine_result_dict[expression.subformula]) ) return results def generate_csv_export(self, options): if len(options['column_groups']) > 1: raise UserError(_("CSV export only works with one column group")) report = self.env['account.report'].browse(options['report_id']) return { 'file_content': self._generate_csv_lazy_export(options), 'file_type': 'csv', 'file_name': report.get_default_report_filename(options, 'csv') } def _generate_csv_lazy_export(self, options): with self.pool.cursor() as new_cr: self.env.flush_all() handler = self.with_env(self.env(cr=new_cr)) cur_data = { currency.id: {'name': currency.name, 'decimal_places': currency.decimal_places} for currency in handler.env['res.currency'].with_context(active_test=False).search([]) } company_currency_id = handler.env.company.currency_id.id def csv_format_account_line(account_line, has_code=True): if has_code: cells = list(handler.env['account.account']._split_code_name(account_line['name'])) else: cells = ['/', account_line['name']] for col in account_line['columns']: cell = col['name'] currency_id = col['currency'].id if col.get('currency') else company_currency_id if col['figure_type'] == 'monetary' and isinstance(cell, float): cell = float_repr(cell, cur_data[currency_id]['decimal_places']) if col['expression_label'] == 'amount_currency': cells.append(cell) cell = cur_data[currency_id]['name'] if currency_id != company_currency_id else '' cells.append(cell) return csv_format(cells) def csv_format_aml_res(aml_res): cells = ['', aml_res.get('move_name', '')] for col in options['columns']: cell = aml_res.get(col['expression_label'], '') if col['figure_type'] == 'monetary' and isinstance(cell, float): if col['expression_label'] == 'amount_currency': currency_id = aml_res['currency_id'] amount_cur_cell = float_repr(cell, cur_data[currency_id]['decimal_places']) if currency_id != company_currency_id else '' cells.append(amount_cur_cell) cell = cur_data[currency_id]['name'] if currency_id != company_currency_id else '' else: cell = float_repr(cell, cur_data[company_currency_id]['decimal_places']) cells.append(cell) return csv_format(cells) def csv_format(cells): with io.StringIO() as buf: writer = csv.writer(buf, delimiter=',', lineterminator='\n') writer.writerow(cells) return buf.getvalue().encode() col_names = [col['name'] for col in options['columns']] currency_idx = next((i for i, col in enumerate(options['columns']) if col.get('expression_label') == 'amount_currency'), None) header = [_("Code"), _("Name")] if currency_idx is not None: header += [ *col_names[:currency_idx], _("Amount Currency"), _("Currency"), *col_names[currency_idx + 1:] ] else: header += col_names yield csv_format(header) report = handler.env['account.report'].browse(options['report_id']) agg_lines_options = report.get_options(previous_options={**options, 'unfolded_lines': [], 'force_not_unfold_all': True}) agg_lines = report.with_context(no_format=True)._get_lines(agg_lines_options) # Exclude total lines account_lines = [] accounts = [] for agg_line in agg_lines: line_id = agg_line['id'] markup, model, res_id = report._parse_line_id(line_id)[-1] if markup != 'total': if model == 'account.account': accounts.append(res_id) account_lines.append(agg_line) if not account_lines: return accounts_with_codes = {account.id for account in handler.env['account.account'].browse(accounts) if account.code} account_lines_iter = iter(account_lines) account_line = next(account_lines_iter) _model, account_id = report._get_model_info_from_id(account_line['id']) yield csv_format_account_line(account_line, has_code=account_id in accounts_with_codes) aml_query = handler._get_query(options, 'id_with_accumulated_balance', order_by_account=True) handler.env.cr.execute(SQL("%s", aml_query)) progress = 0 while aml_line := handler.env.cr.dictfetchone(): while account_id != aml_line['account_id']: account_line = next(account_lines_iter) _model, account_id = report._get_model_info_from_id(account_line['id']) yield csv_format_account_line(account_line, has_code=account_id in accounts_with_codes) progress = 0 if aml_line['id'] is None: aml_line['move_name'] = _("Initial Balance") aml_line['partner_name'] = '' progress = aml_line['balance'] = (progress + aml_line['balance']) yield csv_format_aml_res(aml_line) # These are the "Result Brought Forward" lines for account_line in account_lines_iter: yield csv_format_account_line(account_line, has_code=False) total_line = agg_lines[-1] yield csv_format_account_line(total_line)