459 lines
23 KiB
Python
459 lines
23 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from collections import defaultdict
|
|
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import float_repr, SQL
|
|
|
|
from odoo import api, fields, models, release, _
|
|
|
|
|
|
class AccountGeneralLedgerReportHandler(models.AbstractModel):
|
|
_inherit = 'account.general.ledger.report.handler'
|
|
|
|
def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
|
|
company = self.env.company
|
|
args = []
|
|
if not company.company_registry:
|
|
args.append(_('the Company ID'))
|
|
if not (company.phone):
|
|
args.append(_('the phone number'))
|
|
if not (company.zip or company.city):
|
|
args.append(_('the city or zip code'))
|
|
|
|
if args:
|
|
warnings['account_saft.company_data_warning'] = {
|
|
'alert_type': 'warning',
|
|
'args': _(', ').join(args),
|
|
}
|
|
|
|
####################################################
|
|
# ACTIONS
|
|
####################################################
|
|
|
|
def action_fill_company_details(self, options, params):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Missing company details.'),
|
|
'res_model': 'res.company',
|
|
'views': [(False, 'form')],
|
|
'res_id': self.env.company.id,
|
|
}
|
|
|
|
def _saft_get_account_type(self, account_type):
|
|
"""To be overridden if specific account types are needed.
|
|
Some countries need to specify an account type, unique to the saf-t report.
|
|
|
|
:return: False if no account type needed, otherwise a string with the account type"""
|
|
return False
|
|
|
|
def _get_report_values(self, report, options, values=None):
|
|
"""
|
|
Get the report values based on lines. While parsing the lines, check for the existence of the
|
|
undistributed earnings line, which should be added to values['errors'].
|
|
:return dict: Keys are account_ids pointing to a dict of values
|
|
- account_id:
|
|
- sum {'debit': float, 'credit': float, 'balance': float}
|
|
- (optional) initial_balance: float
|
|
"""
|
|
options = report.get_options(previous_options={**options, 'export_mode': 'file', 'ignore_totals_below_sections': True, 'unfold_all': True})
|
|
lines = report._get_lines(options)
|
|
|
|
colname_to_idx = {col['expression_label']: idx for idx, col in enumerate(options.get('columns', []))}
|
|
current_account_id = 0
|
|
report_values = defaultdict(dict)
|
|
|
|
for line in lines:
|
|
model, res_id = report._get_model_info_from_id(line['id'])
|
|
if model == 'account.account':
|
|
current_account_id = res_id
|
|
report_values[current_account_id]['sum'] = {
|
|
'debit': line['columns'][colname_to_idx['debit']]['no_format'],
|
|
'credit': line['columns'][colname_to_idx['credit']]['no_format'],
|
|
'balance': line['columns'][colname_to_idx['balance']]['no_format'],
|
|
}
|
|
elif values and report._get_markup(line['id']) == 'undistributed_profits_losses':
|
|
balance_index = [c['expression_label'] for c in options['columns']].index('balance')
|
|
currency_id = self.env.company.currency_id
|
|
if not currency_id.is_zero(line['columns'][balance_index]['no_format']):
|
|
values['errors']['undistributed_earnings'] = {
|
|
'message': _('Undistributed profits or losses may cause export rejection.'),
|
|
'level': 'info',
|
|
}
|
|
if (isinstance(res_id, str) and 'balance_line' in res_id):
|
|
report_values[current_account_id]['initial_balance'] = line['columns'][colname_to_idx['balance']]['no_format']
|
|
return report_values
|
|
|
|
@api.model
|
|
def _get_formatted_account_vals_list(self, accounts, report_values):
|
|
account_vals_list = []
|
|
for account in accounts:
|
|
account_group_value = report_values[account.id]
|
|
account_vals_list.append(
|
|
{
|
|
'account': account,
|
|
'account_type': dict(self.env['account.account']._fields['account_type']._description_selection(self.env))[account.account_type],
|
|
'saft_account_type': self._saft_get_account_type(account.account_type),
|
|
'opening_balance': account_group_value.get('initial_balance', 0.0),
|
|
'closing_balance': account_group_value.get('sum', {}).get('balance', 0.0),
|
|
}
|
|
)
|
|
|
|
return account_vals_list
|
|
|
|
@api.model
|
|
def _saft_fill_report_general_ledger_accounts(self, report, options, values):
|
|
report_values = self._get_report_values(report, options, values)
|
|
accounts = self.env['account.account'].browse(report_values.keys())
|
|
values['account_vals_list'] = self._get_formatted_account_vals_list(accounts, report_values)
|
|
|
|
def _saft_fill_report_general_ledger_entries(self, report, options, values):
|
|
res = {
|
|
'total_debit_in_period': 0.0,
|
|
'total_credit_in_period': 0.0,
|
|
'journal_vals_list': [],
|
|
'move_vals_list': [],
|
|
'tax_detail_per_line_map': {},
|
|
}
|
|
# Fill 'total_debit_in_period', 'total_credit_in_period', 'move_vals_list'.
|
|
query = report._get_report_query(options, 'strict_range')
|
|
tax_name = self.env['account.tax']._field_to_sql('tax', 'name')
|
|
journal_name = self.env['account.journal']._field_to_sql('journal', 'name')
|
|
uom_name = self.env['uom.uom']._field_to_sql('uom', 'name')
|
|
product_name = self.env['product.template']._field_to_sql('product_template', 'name')
|
|
account_code = self.env['account.account']._field_to_sql('account', 'code')
|
|
query = SQL(
|
|
'''
|
|
SELECT
|
|
account_move_line.id,
|
|
account_move_line.display_type,
|
|
account_move_line.date,
|
|
account_move_line.name,
|
|
account_move_line.account_id,
|
|
account_move_line.partner_id,
|
|
account_move_line.currency_id,
|
|
account_move_line.amount_currency,
|
|
account_move_line.debit,
|
|
account_move_line.credit,
|
|
account_move_line.balance,
|
|
account_move_line.tax_line_id,
|
|
account_move_line.quantity,
|
|
account_move_line.price_unit,
|
|
account_move_line.product_id,
|
|
account_move_line.product_uom_id,
|
|
account_move_line.tax_base_amount,
|
|
account_move.id AS move_id,
|
|
account_move.name AS move_name,
|
|
account_move.move_type AS move_type,
|
|
account_move.create_date AS move_create_date,
|
|
account_move.invoice_date AS move_invoice_date,
|
|
account_move.invoice_origin AS move_invoice_origin,
|
|
account_move.statement_line_id AS move_statement_line_id,
|
|
tax.id AS tax_id,
|
|
%(tax_name)s AS tax_name,
|
|
tax.amount AS tax_amount,
|
|
tax.amount_type AS tax_amount_type,
|
|
journal.id AS journal_id,
|
|
journal.code AS journal_code,
|
|
%(journal_name)s AS journal_name,
|
|
journal.type AS journal_type,
|
|
account.account_type AS account_type,
|
|
%(account_code)s AS account_code,
|
|
currency.name AS currency_code,
|
|
%(product_name)s AS product_name,
|
|
product.default_code AS product_default_code,
|
|
%(uom_name)s AS product_uom_name
|
|
FROM %(table_references)s
|
|
JOIN account_move ON account_move.id = account_move_line.move_id
|
|
JOIN account_journal journal ON journal.id = account_move_line.journal_id
|
|
JOIN account_account account ON account.id = account_move_line.account_id
|
|
JOIN res_currency currency ON currency.id = account_move_line.currency_id
|
|
LEFT JOIN product_product product ON product.id = account_move_line.product_id
|
|
LEFT JOIN product_template product_template ON product_template.id = product.product_tmpl_id
|
|
LEFT JOIN uom_uom uom ON uom.id = account_move_line.product_uom_id
|
|
LEFT JOIN account_tax tax ON tax.id = account_move_line.tax_line_id
|
|
WHERE %(search_condition)s
|
|
ORDER BY account_move_line.date, account_move_line.id
|
|
''',
|
|
tax_name=tax_name,
|
|
journal_name=journal_name,
|
|
account_code=account_code,
|
|
product_name=product_name,
|
|
uom_name=uom_name,
|
|
table_references=query.from_clause,
|
|
search_condition=query.where_clause,
|
|
)
|
|
self.env.cr.execute(query)
|
|
|
|
journal_vals_map = {}
|
|
move_vals_map = {}
|
|
inbound_types = self.env['account.move'].get_inbound_types(include_receipts=True)
|
|
while True:
|
|
batched_line_vals = self.env.cr.dictfetchmany(10**4)
|
|
if not batched_line_vals:
|
|
break
|
|
for line_vals in batched_line_vals:
|
|
line_vals['rate'] = abs(line_vals['amount_currency']) / abs(line_vals['balance']) if line_vals['balance'] else 1.0
|
|
line_vals['tax_detail_vals_list'] = []
|
|
|
|
journal_vals_map.setdefault(line_vals['journal_id'], {
|
|
'id': line_vals['journal_id'],
|
|
'name': line_vals['journal_name'],
|
|
'code': line_vals['journal_code'],
|
|
'type': line_vals['journal_type'],
|
|
'move_vals_map': {},
|
|
})
|
|
journal_vals = journal_vals_map[line_vals['journal_id']]
|
|
|
|
move_vals = {
|
|
'id': line_vals['move_id'],
|
|
'name': line_vals['move_name'],
|
|
'type': line_vals['move_type'],
|
|
'sign': -1 if line_vals['move_type'] in inbound_types else 1,
|
|
'invoice_date': line_vals['move_invoice_date'],
|
|
'invoice_origin': line_vals['move_invoice_origin'],
|
|
'date': line_vals['date'],
|
|
'create_date': line_vals['move_create_date'],
|
|
'partner_id': line_vals['partner_id'],
|
|
'journal_type': line_vals['journal_type'],
|
|
'statement_line_id': line_vals['move_statement_line_id'],
|
|
'line_vals_list': [],
|
|
}
|
|
move_vals_map.setdefault(line_vals['move_id'], move_vals)
|
|
journal_vals['move_vals_map'].setdefault(line_vals['move_id'], move_vals)
|
|
|
|
computed_line_name = f"[{line_vals['product_default_code']}] {line_vals['product_name']}" if line_vals['product_default_code'] else line_vals['product_name'] or ''
|
|
line_vals['name'] = computed_line_name if not line_vals['name'] else line_vals['name']
|
|
move_vals = move_vals_map[line_vals['move_id']]
|
|
move_vals['line_vals_list'].append(line_vals)
|
|
|
|
# Track the total debit/period of the whole period.
|
|
res['total_debit_in_period'] += line_vals['debit']
|
|
res['total_credit_in_period'] += line_vals['credit']
|
|
|
|
res['tax_detail_per_line_map'][line_vals['id']] = line_vals
|
|
|
|
# Fill 'journal_vals_list'.
|
|
for journal_vals in journal_vals_map.values():
|
|
journal_vals['move_vals_list'] = list(journal_vals.pop('move_vals_map').values())
|
|
res['journal_vals_list'].append(journal_vals)
|
|
res['move_vals_list'] += journal_vals['move_vals_list']
|
|
|
|
values.update(res)
|
|
|
|
@api.model
|
|
def _saft_fill_report_tax_details_values(self, report, options, values):
|
|
tax_vals_map = {}
|
|
|
|
query = report._get_report_query(options, 'strict_range')
|
|
tax_details_query = self.env['account.move.line']._get_query_tax_details(query.from_clause, query.where_clause)
|
|
tax_name = self.env['account.tax']._field_to_sql('tax', 'name')
|
|
self.env.cr.execute(SQL('''
|
|
SELECT
|
|
tax_detail.base_line_id,
|
|
tax_line.currency_id,
|
|
tax.id AS tax_id,
|
|
tax.type_tax_use AS tax_type,
|
|
tax.amount_type AS tax_amount_type,
|
|
%(tax_name)s AS tax_name,
|
|
tax.amount AS tax_amount,
|
|
tax.create_date AS tax_create_date,
|
|
SUM(tax_detail.tax_amount) AS amount,
|
|
SUM(tax_detail.tax_amount) AS amount_currency,
|
|
SUM(tax_detail.base_amount) AS tax_base_amount
|
|
FROM (%(tax_details_query)s) AS tax_detail
|
|
JOIN account_move_line tax_line ON tax_line.id = tax_detail.tax_line_id
|
|
JOIN account_tax tax ON tax.id = tax_detail.tax_id
|
|
WHERE SIGN(tax_detail.tax_amount) = SIGN(tax_detail.base_amount)
|
|
GROUP BY tax_detail.base_line_id, tax_line.currency_id, tax.id
|
|
''', tax_name=tax_name, tax_details_query=tax_details_query))
|
|
for tax_vals in self.env.cr.dictfetchall():
|
|
line_vals = values['tax_detail_per_line_map'][tax_vals['base_line_id']]
|
|
line_vals['tax_detail_vals_list'].append({
|
|
**tax_vals,
|
|
'rate': line_vals['rate'],
|
|
'currency_code': line_vals['currency_code'],
|
|
'tax_base_amount': tax_vals['tax_base_amount'],
|
|
})
|
|
tax_vals_map.setdefault(tax_vals['tax_id'], {
|
|
'id': tax_vals['tax_id'],
|
|
'name': tax_vals['tax_name'],
|
|
'amount': tax_vals['tax_amount'],
|
|
'amount_type': tax_vals['tax_amount_type'],
|
|
'type': tax_vals['tax_type'],
|
|
'create_date': tax_vals['tax_create_date']
|
|
})
|
|
|
|
# Fill 'tax_vals_list'.
|
|
values['tax_vals_list'] = list(tax_vals_map.values())
|
|
|
|
def _get_all_partners(self, values, balance_result):
|
|
return self.env['res.partner'].browse([partner_id for partner_id, *__ in balance_result])
|
|
|
|
@api.model
|
|
def _saft_fill_report_partner_ledger_values(self, report, options, values):
|
|
res = {
|
|
'customer_vals_list': [],
|
|
'supplier_vals_list': [],
|
|
'partner_detail_map': defaultdict(lambda: {
|
|
'type': False, # TODO: remove in master
|
|
'types': [],
|
|
'addresses': [],
|
|
'contacts': [],
|
|
}),
|
|
}
|
|
|
|
# Fill 'customer_vals_list' and 'supplier_vals_list'
|
|
all_entries = options.get('all_entries')
|
|
domain = [
|
|
'&', '|', '&',
|
|
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
|
|
('parent_state', '!=', 'cancel') if all_entries else ('parent_state', '=', 'posted'),
|
|
('account_id.account_type', 'in', ('asset_fixed', 'asset_current', 'asset_non_current')),
|
|
('partner_id', '!=', False),
|
|
]
|
|
# If "all_entries" option is not True, "_get_report_query" adds a condition to include
|
|
# posted entries only.
|
|
# However depreciation lines could still be in draft for the desired period and need
|
|
# to be retrieved to populate the customers/suppliers list.
|
|
# "all_entries" should be forced to True in the options when calling "_get_report_query"
|
|
# in order to retrieve entries that are not posted.
|
|
# Use a copy of the options to do so to not impact subsequent use of the options.
|
|
options_copy = options.copy()
|
|
options_copy['all_entries'] = True
|
|
query = report._get_report_query(options_copy, 'from_beginning', domain=domain)
|
|
alias = query.join(lhs_alias=query.table, lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account')
|
|
query.groupby = SQL.identifier(query.table, "partner_id")
|
|
query.having = SQL(
|
|
"MIN(date) FILTER (WHERE date >= %(date_from)s AND date <= %(date_to)s) IS NOT NULL",
|
|
date_from=options['date']['date_from'],
|
|
date_to=options['date']['date_to'],
|
|
)
|
|
balance_result = self.env.execute_query(query.select(
|
|
SQL.identifier(query.table, "partner_id"),
|
|
SQL("BOOL_OR(%s = 'asset_receivable') AS any_receivable", SQL.identifier(alias, "account_type")),
|
|
SQL("BOOL_OR(%s = 'liability_payable') AS any_liability", SQL.identifier(alias, "account_type")),
|
|
SQL("COALESCE(SUM(balance) FILTER (WHERE date < %s AND %s = 'asset_receivable'), 0) AS opening_receivable", options['date']['date_from'], SQL.identifier(alias, "account_type")),
|
|
SQL("COALESCE(SUM(balance) FILTER (WHERE %s = 'asset_receivable'), 0) AS closing_receivable", SQL.identifier(alias, "account_type")),
|
|
SQL("COALESCE(SUM(balance) FILTER (WHERE date < %s AND %s = 'liability_payable'), 0) AS opening_payable", options['date']['date_from'], SQL.identifier(alias, "account_type")),
|
|
SQL("COALESCE(SUM(balance) FILTER (WHERE %s = 'liability_payable'), 0) AS closing_payable", SQL.identifier(alias, "account_type")),
|
|
))
|
|
all_partners = self._get_all_partners(values, balance_result)
|
|
for partner_id, any_receivable, any_liability, opening_receivable, closing_receivable, opening_payable, closing_payable in balance_result:
|
|
partner = self.env['res.partner'].browse(partner_id).with_prefetch(all_partners._prefetch_ids)
|
|
|
|
# TODO: remove in master: keeping a valid value in type in case the users does not have the updated xml
|
|
partner_type = 'customer' if partner.customer_rank >= partner.supplier_rank else 'supplier'
|
|
res['partner_detail_map'][partner_id]['type'] = partner_type
|
|
|
|
if any_liability or partner_type == 'supplier':
|
|
res['partner_detail_map'][partner_id]['types'].append('supplier')
|
|
res['supplier_vals_list'].append({
|
|
'partner': partner,
|
|
'opening_balance': opening_payable,
|
|
'closing_balance': closing_payable,
|
|
})
|
|
|
|
if any_receivable or partner_type == 'customer':
|
|
res['partner_detail_map'][partner_id]['types'].append('customer')
|
|
res['customer_vals_list'].append({
|
|
'partner': partner,
|
|
'opening_balance': opening_receivable,
|
|
'closing_balance': closing_receivable,
|
|
})
|
|
|
|
# Fill 'partner_detail_map'.
|
|
all_partners |= values['company'].partner_id
|
|
partner_addresses_map = defaultdict(dict)
|
|
partner_contacts_map = defaultdict(lambda: self.env['res.partner'])
|
|
|
|
def _track_address(current_partner, partner):
|
|
if partner.zip and partner.city or (options.get('saft_allow_empty_address') and partner != values['company'].partner_id):
|
|
address_key = (partner.zip, partner.city)
|
|
partner_addresses_map[current_partner][address_key] = partner
|
|
|
|
def _track_contact(current_partner, partner):
|
|
partner_contacts_map[current_partner] |= partner
|
|
|
|
for partner in all_partners:
|
|
_track_address(partner, partner)
|
|
# For individual partners, they are their own ContactPerson.
|
|
# For company partners, the child contact with lowest ID is the ContactPerson.
|
|
# For the current company, all child contacts are ContactPersons
|
|
# (to give users flexibility to indicate several ContactPersons).
|
|
if partner.is_company:
|
|
children = partner.child_ids.filtered(lambda p: p.type == 'contact' and p.active and not p.is_company).sorted('id')
|
|
if partner == values['company'].partner_id:
|
|
if not children:
|
|
values['errors']['missing_company_contact'] = {
|
|
'message': _('Please define one or more Contacts belonging to your company.'),
|
|
'action_text': _('View Company Partner'),
|
|
'action': partner._get_records_action(name=_("Define Contact(s)")),
|
|
'level': 'danger',
|
|
}
|
|
for child in children:
|
|
_track_contact(partner, child)
|
|
elif children:
|
|
_track_contact(partner, children[0])
|
|
else:
|
|
_track_contact(partner, partner)
|
|
|
|
no_partner_address = self.env['res.partner']
|
|
for partner in all_partners:
|
|
res['partner_detail_map'][partner.id].update({
|
|
'partner': partner,
|
|
'addresses': list(partner_addresses_map[partner].values()),
|
|
'contacts': partner_contacts_map[partner],
|
|
})
|
|
if not res['partner_detail_map'][partner.id]['addresses']:
|
|
no_partner_address |= partner
|
|
|
|
if no_partner_address:
|
|
values['errors']['missing_partner_zip_city'] = {
|
|
'message': _('Some partners are missing at least one address (Zip/City).'),
|
|
'action_text': _('View Partners'),
|
|
'action': no_partner_address._get_records_action(name=_("Partners to be checked")),
|
|
}
|
|
|
|
# Add newly computed values to the final template values.
|
|
values.update(res)
|
|
|
|
@api.model
|
|
def _saft_prepare_report_initial_values(self, options, values):
|
|
def format_float(amount, digits=2):
|
|
return float_repr(amount or 0.0, precision_digits=digits)
|
|
|
|
def format_date(date_str, formatter):
|
|
date_obj = fields.Date.to_date(date_str)
|
|
return date_obj.strftime(formatter)
|
|
|
|
values.update({
|
|
'company': self.env.company,
|
|
'xmlns': '',
|
|
'file_version': 'undefined',
|
|
'accounting_basis': 'undefined',
|
|
'today_str': fields.Date.to_string(fields.Date.context_today(self)),
|
|
'software_version': release.version,
|
|
'date_from': options['date']['date_from'],
|
|
'date_to': options['date']['date_to'],
|
|
'format_float': format_float,
|
|
'format_date': format_date,
|
|
'errors': {},
|
|
})
|
|
|
|
@api.model
|
|
def _saft_prepare_report_values(self, report, options):
|
|
|
|
if len(options["column_groups"]) > 1:
|
|
raise UserError(_("SAF-T is only compatible with one column group."))
|
|
|
|
report._init_currency_table(options)
|
|
options["single_column_group"] = next(iter(options["column_groups"].keys()))
|
|
|
|
template_values = {}
|
|
self._saft_prepare_report_initial_values(options, template_values)
|
|
self._saft_fill_report_general_ledger_accounts(report, options, template_values)
|
|
self._saft_fill_report_general_ledger_entries(report, options, template_values)
|
|
self._saft_fill_report_tax_details_values(report, options, template_values)
|
|
self._saft_fill_report_partner_ledger_values(report, options, template_values)
|
|
return template_values
|