Files
enterprise-addons/l10n_se_sie4_import/wizard/import_wizard.py
T

502 lines
23 KiB
Python

import base64
import csv
import re
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from odoo import _, api, Command, fields, models, modules, tools
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
DATEFORMAT_SIE4 = '%Y%m%d'
DATEFORMAT_MAIN = DEFAULT_SERVER_DATE_FORMAT
class SIE4ImportWizard(models.TransientModel):
_name = 'l10n_se_sie4_import.wizard'
_description = "Accounting SIE 4 import wizard"
attachment_name = fields.Char(string="Filename")
attachment_file = fields.Binary(string="SIE 4 File", required=True)
company_id = fields.Many2one(comodel_name='res.company', required=True, default=lambda self: self.env.company)
update_account_data = fields.Boolean(
string="Update Chart of Account data",
help="Update existing accounts information with the file's information.",
)
import_opening_balance = fields.Boolean(
string="Import account opening balances",
help="Compare opening balance information from Odoo with the file and create a move with the difference found.",
)
# --------------------------------------------------------------------------
# IMPORT KEY HANDLERS (in order)
# --------------------------------------------------------------------------
# 1. Identification items
# --------------------------------------------------------------------------
def _import_key_orgnr(self, data_map, org_number):
"""
Handles the #ORGNR item.
Specifies the swedish organization number of the imported company.
:param str org_number:
"""
data_map['res.company'][self.company_id.id].update({
'vat': f"SE{org_number.replace('-', '')}01",
})
def _import_key_adress(self, data_map, addr_detail, addr_main, telephone):
"""
Handles the #ADRESS item.
Specifies the address information of the imported company.
:param str addr_detail:
:param str addr_main:
:param str telephone:
"""
data_map['res.company'][self.company_id.id].update({
'street': addr_main,
'street2': addr_detail,
'phone': telephone,
})
def _import_key_fnamn(self, data_map, company_name):
"""
Handles the #FNAMN item.
Specifies the imported company name.
:param str company_name:
"""
data_map['res.company'][self.company_id.id].update({
'name': company_name,
})
def _import_key_rar(self, data_map, year_idx, date_from, date_to):
"""
Handles all #RAR items.
Specifies the financial year dates of the exported data.
:param str year_idx: '0' for current year, '-1' for previous year.
:param str date_from: start date string in the main format
:param str date_to: end date string in the main format
"""
date_from = self._get_standard_date_str(date_from)
date_to = self._get_standard_date_str(date_to)
data_map['dates'][year_idx] = {
'date_from': date_from,
'date_to': date_to,
}
if year_idx == '0': # also add info for next year
data_map['dates']['1'] = {
'date_from': (datetime.strptime(date_from, DATEFORMAT_MAIN) + relativedelta(years=1)).strftime(DATEFORMAT_MAIN),
'date_to': (datetime.strptime(date_to, DATEFORMAT_MAIN) + relativedelta(years=1)).strftime(DATEFORMAT_MAIN),
}
# --------------------------------------------------------------------------
# 2. Chart of accounts information items
# --------------------------------------------------------------------------
def _import_key_konto(self, data_map, account_code, account_name):
"""
Handles all #KONTO items.
Specifies the name of the account related to the mentioned code.
:param str account_code:
:param str account_name:
"""
if account_code in data_map['existing_accounts_map']:
if self.update_account_data:
account_id = data_map['existing_accounts_map'][account_code]
matching_account = self.env['account.account'].browse(account_id)
if matching_account.name != account_name:
data_map['account.account'].setdefault(matching_account.id, {})
data_map['account.account'][matching_account.id].update({'name': account_name})
else:
account_xml_id = self._make_xml_id('account', account_code)
data_map['account.account'].setdefault(account_xml_id, {'company_ids': self.company_id.ids, 'code': account_code})
data_map['account.account'][account_xml_id].update({'name': account_name})
def _import_key_ktyp(self, data_map, account_code, sie4_type_code):
"""
Handles all #KTYP items.
:param str account_code:
:param str sie4_type_code: Can be one of the following: "T" (asset), "S" (debt), "K" (expense), or "I" (income)
"""
match sie4_type_code:
case "T":
account_type = "asset_receivable"
case "S":
account_type = "liability_current"
case "K":
account_type = "expense"
case _: # "I"
account_type = "income"
if account_code in data_map['existing_accounts_map']:
if self.update_account_data:
account_id = data_map['existing_accounts_map'][account_code]
matching_account = self.env['account.account'].browse(account_id)
if matching_account.account_type != account_type:
data_map['account.account'].setdefault(matching_account.id, {})
data_map['account.account'][matching_account.id].update({'account_type': account_type})
else:
account_xml_id = self._make_xml_id('account', account_code)
data_map['account.account'].setdefault(account_xml_id, {'company_ids': self.company_id.ids, 'code': account_code})
data_map['account.account'][account_xml_id].update({'account_type': account_type})
# --------------------------------------------------------------------------
# 3. Balance items / Verification items
# --------------------------------------------------------------------------
def _import_key_ib(self, data_map, year_idx, account_code, balance):
"""
Handles all #IB (opening balance) items.
#IB will only be imported when the import balance preference is checked.
For the first SIE 4 import implementation, we will only import #IB 0 and ignore everything else as their purpose are still unclear.
This method gathers all the mentioned account codes and its balance into the data_map, to be processed as a batch later.
:param str year_idx:
:param str account_code:
:param str balance:
"""
if not self.import_opening_balance or year_idx != '0':
return
data_map['opening_balance_map'][account_code] = float(balance)
def _import_key_ub(self, data_map, year_idx, account_code, balance):
"""
Handles all #UB (closing balance) items.
#UB is the exact same as the opening balance (#IB) of the next year.
In Odoo, we only save the opening balance move, so we don't really need this item.
However, in SIE 4, the file also provides closing balance for the current year.
In that scenario, we should treat them as an opening balance for the next year.
:param str year_idx:
:param str account_code:
:param str balance:
"""
if year_idx == '0': # current year's closing balance
year_idx = '1'
self._import_key_ib(data_map, year_idx, account_code, balance)
def _import_special_key_ver(self, data_map, verification_str, verification_date, verification_text, transactions):
"""
#VER (short for "verification") is a special key; it's always comes with list of transactions to verify.
In Odoo, they are specified as ``account.move``, where each transaction is an ``account.move.line``
:param str verification_str: the unique XML id based on the SIE4 move
:param str verification_date: the date of the move in SIE 4 format
:param str verification_text: the additional detail text of the move. By default, they should be an empty string
:param list[dict] transactions: [{'account_code': <str>, 'balance': <float>}, ...]
"""
move_xml_id = self._make_xml_id('move', verification_str)
line_ids = []
for transaction in transactions:
code = transaction['account_code']
account_id = data_map['existing_accounts_map'].get(code, self._make_xml_id('account', code))
line_ids.append(Command.create({
'account_id': account_id,
'amount_currency': transaction['balance'],
'currency_id': self.company_id.currency_id.id,
}))
move_ref = _("Imported from SIE4")
if verification_text:
move_ref += f" - {verification_text}"
data_map['account.move'][move_xml_id] = {
'company_id': self.company_id.id,
'journal_id': data_map['journal_misc_id'],
'date': self._get_standard_date_str(verification_date),
'line_ids': line_ids,
'ref': move_ref,
}
# --------------------------------------------------------------------------
# UTILITIES
# --------------------------------------------------------------------------
def _make_xml_id(self, prefix, key):
if tools.config['test_enable'] or modules.module.current_test:
return f'test.{prefix}_{key}'
if '_' in prefix:
raise ValueError('`prefix` cannot contain an underscore')
key = key.replace(' ', '_').replace('-', '_')
return f"l10n_se_sie4_import.{self.company_id.id}_{prefix}_{key}"
@api.model
def _get_standard_date_str(self, sie_date):
"""
Convert the SIE 4 date format (ex: '20240131') to the standard format (ex: '2024-01-31')
:param str sie_date: the date string in SIE 4 format
:rtype: str
"""
return datetime.strptime(sie_date, DATEFORMAT_SIE4).strftime(DATEFORMAT_MAIN)
@api.model
def _get_sie4_line_items(self, sie4_line):
"""
The SIE 4 file line general format is designed similarly to how a space-separated CSV works.
All words are its own items, but if double quotes (`""`) or curly braces (`{}`) is used, everything inside it is grouped.
This method converts the sie4 line to the parsed list of items.
:param str sie4_line: a string of a line in the SIE 4 file
:rtype: list[str]
"""
sie4_line = sie4_line.replace('{', '"').replace('}', '"').replace('\t', ' ').strip()
sie4_line = re.sub(r' +', ' ', sie4_line) # filter double spaces between words
reader = csv.reader([sie4_line], delimiter=' ')
return next(reader)
@api.model
def _prepare_line_data(self, line_items):
"""
:param list[str] line_items:
:rtype: dict[str, str]
"""
label = line_items[0]
args = line_items[1:]
match label:
case '#ORGNR':
return {'org_number': args[0]}
case '#ADRESS':
if len(args) < 4:
raise UserError(_(
'Missing element in #ADRESS line.\n'
'Expected format: "contact" "distribution address" "postal address" "telephone".\n'
'Received data: %(data)s.',
data=" ".join(args),
))
return {'addr_detail': args[1], 'addr_main': args[2], 'telephone': args[3]}
case '#FNAMN':
return {'company_name': args[0]}
case '#RAR':
return {'year_idx': args[0], 'date_from': args[1], 'date_to': args[2]}
case '#KONTO':
return {'account_code': args[0], 'account_name': args[1]}
case '#KTYP':
return {'account_code': args[0], 'sie4_type_code': args[1]}
case '#IB' | '#UB':
return {'year_idx': args[0], 'account_code': args[1], 'balance': args[2]}
# --------------------------------------------------------------------------
# ACTION LOGICS
# --------------------------------------------------------------------------
def _generate_sie4_data(self):
"""
Generate sie4_data from the attachment.
The sie4_data will be returned as a list of dictionaries, where each dictionary can be either:
- {'label': <str>, 'args_map': <dict>} for normal items
- {'label': "#VER", 'verification_str': <str>, 'verification_date': <str>, 'transactions': <list[dict]>} for #VER items.
The dictionary fields other than the label are to be used as the parameter for its respective label methods.
"""
sie4_bytes = base64.b64decode(self.attachment_file)
sie4_lines = sie4_bytes.decode('cp437').split('\n')
sie4_data = []
idx = 0
# Use `while` loop instead of `for` because some lines must be grouped together and processed as one item.
# We need to be able to skip several lines at once by modifying the `while`'s index
while idx < len(sie4_lines):
line_items = self._get_sie4_line_items(sie4_lines[idx])
label = line_items[0] if line_items else None
# Handle related contiguous lines from the #VER special item (for generating `account.move`).
# Verification items are always followed with a "\n{\n<lines-of-transaction>\n}\n",
# Where every line of transaction ("#TRANS") corresponds to the `account.move.line` of the move
if label and label == '#VER':
transactions = []
idx += 2 # skips `{` line and read the transaction items immediately
# We don't need the object list for transactions, to keep the same order,
# we delete this part which can have more than one element
inner_line_items = self._get_sie4_line_items(re.sub(r'\{.*}', ' "" ', sie4_lines[idx]))
while sie4_lines[idx].strip() != '}':
if inner_line_items and inner_line_items[0] == '#TRANS': # only accept #TRANS key; ignore #RTRANS and #BTRANS if it exists
transactions.append({
'account_code': inner_line_items[1],
'balance': float(inner_line_items[3]),
})
idx += 1
inner_line_items = self._get_sie4_line_items(re.sub(r"\{.*}", ' "" ', sie4_lines[idx]))
if transactions:
ver_series, ver_nb, ver_date = line_items[1:4]
ver_text = line_items[4] if len(line_items) > 4 else ''
sie4_data.append({
'label': label,
'verification_str': f"sie4_{ver_date}{ver_series}{ver_nb}",
'verification_date': ver_date,
'verification_text': ver_text,
'transactions': transactions,
})
elif label: # normal items (ex: "#KONTO", "#FNAMN", "#IB", etc.)
sie4_data.append({
'label': label,
'data': self._prepare_line_data(line_items),
})
idx += 1
return sie4_data
def _read_sie4_data(self, data_map):
"""
Reads sie4_data and process each recognized label line by line.
Create/Update values are collected to ``data_map`` and will be created after processing the whole file.
"""
sie4_method_map = {
'#ORGNR': self._import_key_orgnr,
'#ADRESS': self._import_key_adress,
'#FNAMN': self._import_key_fnamn,
'#RAR': self._import_key_rar,
'#KONTO': self._import_key_konto,
'#KTYP': self._import_key_ktyp,
'#IB': self._import_key_ib,
'#UB': self._import_key_ub,
}
sie4_data = data_map['sie4_data']
for line_data in sie4_data:
if line_data['label'] == '#VER':
self._import_special_key_ver(
data_map=data_map,
verification_str=line_data['verification_str'],
verification_date=line_data['verification_date'],
verification_text=line_data['verification_text'],
transactions=line_data['transactions'],
)
elif line_data['label'] in sie4_method_map:
import_key_method = sie4_method_map[line_data['label']]
import_key_method(data_map, **line_data['data'])
def _prepare_sie4_opening_balance_move(self, data_map):
"""
This method processes all the gathered account_code-balance map in data_map.
It then compares the difference of the opening balance found in Odoo with the file.
If any difference is found, it will then create an opening balance move with that sum difference.
"""
if '-1' in data_map['dates']:
prev_date_to = data_map['dates']['-1']['date_to']
else:
# Fallback: one day before the IB year start
ib_date_from = data_map['dates']['0']['date_from']
dt = datetime.strptime(ib_date_from, "%Y-%m-%d")
prev_date_to = (dt - timedelta(days=1)).strftime("%Y-%m-%d")
account_codes = list(data_map['opening_balance_map'].keys())
currency = self.company_id.currency_id
account_sum_group = self.env['account.move.line'].with_company(self.company_id)._read_group(
domain=[
('account_id.code', 'in', account_codes),
('date', '<=', prev_date_to),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
],
groupby=['account_id'],
aggregates=['balance:sum'],
)
# If some account codes are not found in the searched group, add them manually with a 0 balance
# to make sure all found accounts from the file's opening balance items have a counterpart to compare
if unseen_account_codes := set(account_codes).difference(group_account.code for group_account, _account_sum in account_sum_group):
unseen_accounts = self.env['account.account'].with_company(self.company_id).search_fetch(
domain=[('code', 'in', tuple(unseen_account_codes))],
field_names=['id', 'code'],
)
for account in unseen_accounts:
account_sum_group.append((account, 0))
line_ids = []
total_diff = 0.0
for account, account_sum in account_sum_group:
if currency.compare_amounts(account_sum, data_map['opening_balance_map'][account.code]) != 0:
diff_balance = data_map['opening_balance_map'][account.code] - account_sum
total_diff += diff_balance
line_ids.append(Command.create({
'date': prev_date_to,
'account_id': account.id,
'amount_currency': diff_balance,
'currency_id': currency.id,
'journal_id': data_map['journal_misc_id'],
}))
if not currency.is_zero(total_diff):
off_balance_account = self.env['account.account'].with_company(self.company_id).search_fetch(
domain=[('account_type', '=', 'equity_unaffected')],
field_names=['id'],
limit=1,
)
line_ids.append(Command.create({
'date': prev_date_to,
'account_id': off_balance_account.id,
'amount_currency': -total_diff,
'currency_id': currency.id,
'journal_id': data_map['journal_misc_id'],
}))
if line_ids:
move_xml_id = self._make_xml_id('move', f'opening_balance_{prev_date_to.replace("-", "_")}')
data_map['account.move'][move_xml_id] = {
'date': prev_date_to,
'ref': _('SIE opening balance move %(date)s', date=prev_date_to),
'journal_id': data_map['journal_misc_id'],
'company_id': self.company_id.id,
'move_type': 'entry',
'line_ids': line_ids,
}
def action_import_sie4(self):
self.ensure_one()
existing_accounts_map = {
account['code']: account['id']
for account in self.env['account.account'].with_company(self.company_id).search_read(fields=['id', 'code'])
}
journal_misc = self.env['account.journal'].search_fetch(
domain=[*self.env['account.journal']._check_company_domain(self.company_id), ('type', '=', 'general')],
field_names=['id'],
limit=1,
)
journal_misc_id = journal_misc.id or self._make_xml_id('journal', f"sie4_misc_{self.id}")
data_map = {
'opening_balance_map': {}, # { <account_code(str)> : <account_opening_balance(float)> } for <#IB 0> only
'existing_accounts_map': existing_accounts_map,
'journal_misc_id': journal_misc_id,
'dates': {}, # { <rar_year(str)> : {'date_from': <str>, 'date_to': <str>} }
'sie4_data': self._generate_sie4_data(),
'account.account': {},
'res.company': {self.company_id.id: {}},
'account.fiscal.year': {},
'account.move': {},
}
self._read_sie4_data(data_map)
if data_map['opening_balance_map']:
self._prepare_sie4_opening_balance_move(data_map)
create_keys = ('res.company', 'account.account', 'account.fiscal.year', 'account.move')
if isinstance(journal_misc_id, str): # No MISC journal found; create the MISC journal for the moves to create
create_keys = ('res.company', 'account.account', 'account.journal', 'account.fiscal.year', 'account.move')
data_map['account.journal'] = {
journal_misc_id: {
'name': "SIE4 Miscellaneous",
'type': 'general',
'code': 'MISCSIE4',
}
}
create_data = {key: data_map[key] for key in create_keys if data_map[key]}
created_vals = self.env['account.chart.template']._load_data(create_data)
# Set the sequence of the newly imported moves
if moves := created_vals.get('account.move'):
moves = moves.sorted(lambda m: (m.date, m.ref or '', m._origin.id))
for move in moves:
if not move.name or move.name == '/':
move._set_next_sequence()
import_summary = self.env['account.import.summary'].create({
'import_summary_account_ids': created_vals.get("account.account"),
'import_summary_journal_ids': created_vals.get("account.journal"),
'import_summary_move_ids': created_vals.get("account.move"),
})
return import_summary.action_open_summary_view()