from datetime import datetime from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models, release from odoo.exceptions import UserError from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, SQL DATEFORMAT_SIE4 = '%Y%m%d' DATEFORMAT_MAIN = DEFAULT_SERVER_DATE_FORMAT class GeneralLedgerCustomHandler(models.AbstractModel): _inherit = 'account.general.ledger.report.handler' def _custom_options_initializer(self, report, options, previous_options=None): super()._custom_options_initializer(report, options, previous_options) if self.env.company.account_fiscal_country_id.code == 'SE': options.setdefault('buttons', []).append({ 'name': _("SIE 4"), 'sequence': 50, 'action': 'export_file', 'action_param': 'export_l10n_se_sie4_file', 'file_export_type': _("SIE") }) @api.model def _get_l10n_se_sie4_dates(self, options, use_sie4_format=False): """ Returns a dictionary of date strings from previous year's start to next year's end in the desired format. :param options: ``account.report`` options dictionary :param use_sie4_format: desired format. ex: without sie4_format '2024-01-01' -> with sie4_format '20240101' """ result_strf = DATEFORMAT_SIE4 if use_sie4_format else DATEFORMAT_MAIN datetime_from = datetime.strptime(options['date']['date_from'], DATEFORMAT_MAIN) datetime_to = datetime.strptime(options['date']['date_to'], DATEFORMAT_MAIN) previous_fiscal_year = self.env['account.fiscal.year'].search([ ('date_to', '<=', options['date']['date_from']) ], order='date_to desc', limit=1) prev_date_from = previous_fiscal_year.date_from if previous_fiscal_year else (datetime_from - relativedelta(years=1)) prev_date_to = previous_fiscal_year.date_to if previous_fiscal_year else (datetime_from - relativedelta(days=1)) return { 'prev_date_from': prev_date_from.strftime(result_strf), 'prev_date_to': prev_date_to.strftime(result_strf), 'curr_date_from': datetime_from.strftime(result_strf), 'curr_date_to': datetime_to.strftime(result_strf), 'next_date_from': (datetime_to + relativedelta(days=1)).strftime(result_strf), 'next_date_to': (datetime_to + relativedelta(years=1)).strftime(result_strf), } @api.model def _get_l10n_se_sie4_ktyp(self, account_type): group_asset = {'asset_receivable', 'asset_cash', 'asset_current', 'asset_non_current', 'asset_prepayments', 'asset_fixed'} group_debt = {'liability_payable', 'liability_credit_card', 'liability_current', 'liability_non_current', 'equity', 'equity_unaffected', 'off_balance'} group_cost = {'expense', 'expense_depreciation', 'expense_direct_cost'} if account_type in group_asset: return 'T' if account_type in group_debt: return 'S' if account_type in group_cost: return 'K' return 'I' # income type @api.model def _get_l10n_se_sie4_options(self, report, date_from, date_to): return report.get_options({ 'date': { 'date_from': date_from, 'date_to': date_to, 'mode': 'range', 'filter': 'custom', }, 'export_mode': 'file', }) @api.model def _export_l10n_se_sie4_identification(self, options): dates = self._get_l10n_se_sie4_dates(options, use_sie4_format=True) return [ '#FLAGGA 0', '#FORMAT PC8', '#SIETYP 4', f'#PROGRAM "Odoo" {release.version}', f'#GEN {fields.Date.context_today(self).strftime(DATEFORMAT_SIE4)}', f'#FNAMN "{options["companies"][0]["name"]}"', f'#RAR -1 {dates["prev_date_from"]} {dates["prev_date_to"]}', f'#RAR 0 {dates["curr_date_from"]} {dates["curr_date_to"]}', ] @api.model def _export_l10n_se_sie4_chart_of_account(self, options): sie4_coa_lines = [] company_id = options['companies'][0]['id'] accounts = self.env['account.account'].with_company(company_id).search([]) for account in accounts: sie4_coa_lines.extend(( f'#KONTO {account.code} "{account.name}"', f'#KTYP {account.code} {self._get_l10n_se_sie4_ktyp(account.account_type)}', )) return sie4_coa_lines @api.model def _l10n_se_sie4_get_initial_balances_values(self, report, options, filtered_accounts): def get_dict_values_from_report_line(line): return { 'balance': line['columns'][colname_to_idx['balance']]['no_format'], 'debit': line['columns'][colname_to_idx['debit']]['no_format'], 'credit': line['columns'][colname_to_idx['credit']]['no_format'], } options = { **options, 'unfold_all': True, } colname_to_idx = {col['expression_label']: idx for idx, col in enumerate(options.get('columns', []))} lines = report._get_lines(options) initial_balances = {} account_by_ids = {account.id: account for account in filtered_accounts} for line in lines: _model, res_id = report._get_model_info_from_id(line['id']) if isinstance(res_id, str) and 'balance_line' in res_id: account_id = report._get_res_id_from_line_id(line['id'], 'account.account') account = account_by_ids.get(account_id, False) if account: initial_balances[account] = get_dict_values_from_report_line(line) return initial_balances @api.model def _export_l10n_se_sie4_bs_balance(self, options): """ Generates the opening/closing balance lines and return them as a list of string line. In SIE 4, the opening/closing balance lines are saved with the following keywords: - "IB" -> Opening balance - "UB" -> Closing balance The number to the next of them refers to what year they are referring to. "0" means current year, "-1" means previous year. Consequently, this also means that "#UB -1" is the exact same as "#IB 0". """ sie4_bs_balance_lines = [] report = self.env['account.report'].browse(options['report_id']) dates = self._get_l10n_se_sie4_dates(options) company_id = options['companies'][0]['id'] bs_accounts = self.env['account.account'].with_company(company_id).search_fetch( domain=[('include_initial_balance', '=', True)], field_names=['id'], ) seen_bs_account_codes = set() prev_ib, prev_ub, curr_ib, curr_ub = {}, {}, {}, {} # dict[str, str] | {account_code: balance_str_line} prev_year_options = self._get_l10n_se_sie4_options(report, dates['prev_date_from'], dates['prev_date_to']) next_year_options = self._get_l10n_se_sie4_options(report, dates['next_date_from'], dates['next_date_to']) report._init_currency_table(prev_year_options) report._init_currency_table(options) report._init_currency_table(next_year_options) prev_ib_values = self.with_company(company_id)._l10n_se_sie4_get_initial_balances_values(report, prev_year_options, bs_accounts) curr_ib_values = self.with_company(company_id)._l10n_se_sie4_get_initial_balances_values(report, options, bs_accounts) next_ib_values = self.with_company(company_id)._l10n_se_sie4_get_initial_balances_values(report, next_year_options, bs_accounts) for ib_values in (prev_ib_values, curr_ib_values, next_ib_values): for account, ib_item in ib_values.items(): if ib_item != {}: seen_bs_account_codes.add(account.code) if ib_values is prev_ib_values: # for [IB/-1] (previous year opening balance) prev_ib[account.code] = f'#IB -1 {account.code} {ib_item["balance"]}' elif ib_values is curr_ib_values: # for [UB/-1] and [IB/0] (previous year closing balance + current year opening balance) prev_ub[account.code] = f'#UB -1 {account.code} {ib_item["balance"]}' curr_ib[account.code] = f'#IB 0 {account.code} {ib_item["balance"]}' else: # ib_values is next_ib_values # for [UB/0] (current year closing balance) curr_ub[account.code] = f'#UB 0 {account.code} {ib_item["balance"]}' default_ib_values = ('#IB -1', '#UB -1', '#IB 0', '#UB 0') for account_code in sorted(seen_bs_account_codes): for idx, period_ib in enumerate((prev_ib, prev_ub, curr_ib, curr_ub)): if account_code in period_ib: sie4_bs_balance_lines.append(period_ib[account_code]) else: # if the mentioned account doesn't exist in other periods, fill it with a default value default_ib_value = default_ib_values[idx] sie4_bs_balance_lines.append(f'{default_ib_value} {account_code} 0.0') return sie4_bs_balance_lines @api.model def _export_l10n_se_sie4_pl_balance(self, options): sie4_pl_balance_lines = [] dates = self._get_l10n_se_sie4_dates(options) company_id = options['companies'][0]['id'] common_domain = [ ('account_id.include_initial_balance', '=', False), ('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')), ('move_id.state', '!=', 'cancel'), ] prev_year_domain = common_domain + [('date', '>=', dates['prev_date_from']), ('date', '<=', dates['prev_date_to'])] curr_year_domain = common_domain + [('date', '>=', dates['curr_date_from']), ('date', '<=', dates['curr_date_to'])] prev_account_sum_group = self.env['account.move.line'].with_company(company_id)._read_group(prev_year_domain, ['account_id'], ['balance:sum']) curr_account_sum_group = self.env['account.move.line'].with_company(company_id)._read_group(curr_year_domain, ['account_id'], ['balance:sum']) prev_code_sum_map = {account.code: str(account_sum) for account, account_sum in prev_account_sum_group} curr_code_sum_map = {account.code: str(account_sum) for account, account_sum in curr_account_sum_group} seen_account_code = set(prev_code_sum_map.keys()).union(curr_code_sum_map.keys()) for account_code in sorted(seen_account_code): sie4_pl_balance_lines.extend(( f'#RES -1 {account_code} {prev_code_sum_map.get(account_code, "0.0")}', f'#RES 0 {account_code} {curr_code_sum_map.get(account_code, "0.0")}', )) return sie4_pl_balance_lines @api.model def _export_l10n_se_sie4_verification(self, options): sie4_verification_lines = [] report = self.env['account.report'].browse(options.get('report_id')) company_ids = report.get_report_company_ids(options) query = report._get_report_query(options, 'strict_range', []) account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') account_env = self.env['account.account'].with_context(allowed_company_ids=company_ids) account_code = account_env._field_to_sql(account_alias, 'code', query) sql_query = SQL( """ SELECT m.id AS move_id, m.name AS move_name, m.date AS move_date, %(account_code)s AS account_code, account_move_line.balance AS balance FROM %(table)s JOIN account_move m ON m.id = account_move_line.move_id WHERE %(where_clause)s ORDER BY m.date ASC, m.name ASC, m.id ASC, account_move_line.id ASC """, account_code=account_code, table=query.from_clause, where_clause=query.where_clause, ) self.env.flush_all() self.env.cr.execute(sql_query) last_move_id = None verification_idx = 0 for row in self.env.cr.dictfetchall(): current_move_id = row['move_id'] if current_move_id != last_move_id: if last_move_id is not None: sie4_verification_lines.append('}') verification_idx += 1 move_date_str = row['move_date'].strftime(DATEFORMAT_SIE4) sie4_verification_lines.extend(( f'#VER A {verification_idx} {move_date_str} "{row["move_name"]}"', '{' )) last_move_id = current_move_id sie4_verification_lines.append(f' #TRANS {row["account_code"]} {{}} {row["balance"]}') if last_move_id is not None: sie4_verification_lines.append('}') return sie4_verification_lines def export_l10n_se_sie4_file(self, options): if options['date']['period_type'] != 'fiscalyear': raise UserError(_("You must set the period type to be in fiscal year in order to export SIE 4 file.")) if len(options['companies']) > 1: selected_companies = self.env['res.company'].search_fetch( domain=[('id', 'in', [company_dict['id'] for company_dict in options['companies']])], field_names=['chart_template'], ) if len(set(selected_companies.mapped('chart_template'))) >= 2: # we can only export one chart template raise UserError(_("You can't export multiple companies with different templates.")) else: # filter other selected company/ies in the options options['companies'] = [company_dict for company_dict in options['companies'] if company_dict['id'] == self.env.company.id] content_lines = [ *self._export_l10n_se_sie4_identification(options), *self._export_l10n_se_sie4_chart_of_account(options), *self._export_l10n_se_sie4_bs_balance(options), *self._export_l10n_se_sie4_pl_balance(options), *self._export_l10n_se_sie4_verification(options), '', # to make sure the file ends with a new line ] return { 'file_name': f'odoo_sie4_{fields.Date.context_today(self).strftime(DATEFORMAT_SIE4)}.se', 'file_content': '\n'.join(content_lines).encode('437'), 'file_type': 'txt', }