The FEC report contains initial balances. These representing balances on accounts from the previous period. When calculating the initial balances, there is no requirement to include balances from the previous period for an account if they are at zero. When there are settled transactions for many thousands of partners from the previous period, this results in many thousands of initial balance lines in the FEC report that all have a balance of zero. This PR remove initial line with zero balance. closes odoo/odoo#160729 X-original-commit: 1d1beaf4aa02875f1843b99564562f9ed4446ae8 Signed-off-by: Florian Gilbert (flg) <flg@odoo.com>
443 lines
20 KiB
Python
443 lines
20 KiB
Python
#-*- coding:utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
# Copyright (C) 2013-2015 Akretion (http://www.akretion.com)
|
|
|
|
import base64
|
|
import io
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, AccessDenied
|
|
from odoo.tools import float_is_zero, pycompat
|
|
from odoo.tools.misc import get_lang
|
|
from stdnum.fr import siren
|
|
|
|
|
|
class AccountFrFec(models.TransientModel):
|
|
_name = 'account.fr.fec'
|
|
_description = 'Ficher Echange Informatise'
|
|
|
|
date_from = fields.Date(string='Start Date', required=True)
|
|
date_to = fields.Date(string='End Date', required=True)
|
|
fec_data = fields.Binary('FEC File', readonly=True)
|
|
filename = fields.Char(string='Filename', size=256, readonly=True)
|
|
test_file = fields.Boolean()
|
|
export_type = fields.Selection([
|
|
('official', 'Official FEC report (posted entries only)'),
|
|
('nonofficial', 'Non-official FEC report (posted and unposted entries)'),
|
|
], string='Export Type', required=True, default='official')
|
|
excluded_journal_ids = fields.Many2many('account.journal', string="Excluded Journals", domain="[('company_id', 'parent_of', current_company_id)]")
|
|
|
|
@api.onchange('test_file')
|
|
def _onchange_export_file(self):
|
|
if not self.test_file:
|
|
self.export_type = 'official'
|
|
|
|
def _get_where_query(self):
|
|
where_params = {'company_id': self.env.company.id}
|
|
where_query = "am.company_id = %(company_id)s\n"
|
|
# For official report: only use posted entries
|
|
if self.export_type == "official":
|
|
where_query += "AND am.state = 'posted'\n"
|
|
if self.excluded_journal_ids:
|
|
where_params['excluded_journal_ids'] = tuple(self.excluded_journal_ids.ids)
|
|
where_query += "AND am.journal_id NOT IN %(excluded_journal_ids)s\n"
|
|
return where_query, where_params
|
|
|
|
def _do_query_unaffected_earnings(self):
|
|
''' Compute the sum of ending balances for all accounts that are of a type that does not bring forward the balance in new fiscal years.
|
|
This is needed because we have to display only one line for the initial balance of all expense/revenue accounts in the FEC.
|
|
'''
|
|
where_query, where_params = self._get_where_query()
|
|
sql_query = f'''
|
|
SELECT
|
|
'OUV' AS JournalCode,
|
|
'Balance initiale' AS JournalLib,
|
|
'OUVERTURE/' || %(formatted_date_year)s AS EcritureNum,
|
|
%(formatted_date_from)s AS EcritureDate,
|
|
'120/129' AS CompteNum,
|
|
'Benefice (perte) reporte(e)' AS CompteLib,
|
|
'' AS CompAuxNum,
|
|
'' AS CompAuxLib,
|
|
'-' AS PieceRef,
|
|
%(formatted_date_from)s AS PieceDate,
|
|
'/' AS EcritureLib,
|
|
replace(CASE WHEN COALESCE(sum(aml.balance), 0) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
|
|
replace(CASE WHEN COALESCE(sum(aml.balance), 0) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
|
|
'' AS EcritureLet,
|
|
'' AS DateLet,
|
|
%(formatted_date_from)s AS ValidDate,
|
|
'' AS Montantdevise,
|
|
'' AS Idevise
|
|
FROM
|
|
account_move_line aml
|
|
LEFT JOIN account_move am ON am.id=aml.move_id
|
|
JOIN account_account aa ON aa.id = aml.account_id
|
|
WHERE
|
|
{where_query}
|
|
AND am.date < %(date_from)s
|
|
AND aa.include_initial_balance IS NOT TRUE
|
|
'''
|
|
self._cr.execute(sql_query, {
|
|
**where_params,
|
|
'formatted_date_year': self.date_from.year,
|
|
'formatted_date_from': fields.Date.to_string(self.date_from).replace('-', ''),
|
|
'date_from': self.date_from,
|
|
})
|
|
return list(self._cr.fetchone())
|
|
|
|
def _get_company_legal_data(self, company):
|
|
"""
|
|
Dom-Tom are excluded from the EU's fiscal territory
|
|
Those regions do not have SIREN
|
|
sources:
|
|
https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
|
|
http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
|
|
|
|
* Returns the siren if the company is french or an empty siren for dom-tom
|
|
* For non-french companies -> returns the complete vat number
|
|
"""
|
|
dom_tom_group = self.env.ref('l10n_fr.dom-tom')
|
|
is_dom_tom = company.account_fiscal_country_id.code in dom_tom_group.country_ids.mapped('code')
|
|
if not company.vat or is_dom_tom:
|
|
return ''
|
|
elif company.country_id.code == 'FR' and len(company.vat) >= 13 and siren.is_valid(company.vat[4:13]):
|
|
return company.vat[4:13]
|
|
else:
|
|
return company.vat
|
|
|
|
def generate_fec(self):
|
|
#pylint: disable=sql-injection
|
|
self.ensure_one()
|
|
if not (self.env.is_admin() or self.env.user.has_group('account.group_account_user')):
|
|
raise AccessDenied()
|
|
# We choose to implement the flat file instead of the XML
|
|
# file for 2 reasons :
|
|
# 1) the XSD file impose to have the label on the account.move
|
|
# but Odoo has the label on the account.move.line, so that's a
|
|
# problem !
|
|
# 2) CSV files are easier to read/use for a regular accountant.
|
|
# So it will be easier for the accountant to check the file before
|
|
# sending it to the fiscal administration
|
|
today = fields.Date.today()
|
|
if self.date_from > today or self.date_to > today:
|
|
raise UserError(_('You could not set the start date or the end date in the future.'))
|
|
if self.date_from >= self.date_to:
|
|
raise UserError(_('The start date must be inferior to the end date.'))
|
|
|
|
company = self.env.company
|
|
company_legal_data = self._get_company_legal_data(company)
|
|
|
|
header = [
|
|
u'JournalCode', # 0
|
|
u'JournalLib', # 1
|
|
u'EcritureNum', # 2
|
|
u'EcritureDate', # 3
|
|
u'CompteNum', # 4
|
|
u'CompteLib', # 5
|
|
u'CompAuxNum', # 6 We use partner.id
|
|
u'CompAuxLib', # 7
|
|
u'PieceRef', # 8
|
|
u'PieceDate', # 9
|
|
u'EcritureLib', # 10
|
|
u'Debit', # 11
|
|
u'Credit', # 12
|
|
u'EcritureLet', # 13
|
|
u'DateLet', # 14
|
|
u'ValidDate', # 15
|
|
u'Montantdevise', # 16
|
|
u'Idevise', # 17
|
|
]
|
|
|
|
rows_to_write = [header]
|
|
# INITIAL BALANCE
|
|
unaffected_earnings_account = self.env['account.account'].search([
|
|
*self.env['account.account']._check_company_domain(company),
|
|
('account_type', '=', 'equity_unaffected'),
|
|
], limit=1)
|
|
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
|
|
if unaffected_earnings_account:
|
|
#compute the benefit/loss of last year to add in the initial balance of the current year earnings account
|
|
unaffected_earnings_results = self._do_query_unaffected_earnings()
|
|
unaffected_earnings_line = False
|
|
|
|
if self.pool['account.account'].name.translate:
|
|
lang = self.env.user.lang or get_lang(self.env).code
|
|
aa_name = f"COALESCE(aa.name->>'{lang}', aa.name->>'en_US')"
|
|
else:
|
|
aa_name = "aa.name"
|
|
|
|
where_query, where_params = self._get_where_query()
|
|
sql_query = f'''
|
|
SELECT
|
|
'OUV' AS JournalCode,
|
|
'Balance initiale' AS JournalLib,
|
|
'OUVERTURE/' || %(formatted_date_year)s AS EcritureNum,
|
|
%(formatted_date_from)s AS EcritureDate,
|
|
MIN(aa.code) AS CompteNum,
|
|
replace(replace(MIN({aa_name}), '|', '/'), '\t', '') AS CompteLib,
|
|
'' AS CompAuxNum,
|
|
'' AS CompAuxLib,
|
|
'-' AS PieceRef,
|
|
%(formatted_date_from)s AS PieceDate,
|
|
'/' AS EcritureLib,
|
|
replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
|
|
replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
|
|
'' AS EcritureLet,
|
|
'' AS DateLet,
|
|
%(formatted_date_from)s AS ValidDate,
|
|
'' AS Montantdevise,
|
|
'' AS Idevise,
|
|
MIN(aa.id) AS CompteID
|
|
FROM
|
|
account_move_line aml
|
|
LEFT JOIN account_move am ON am.id=aml.move_id
|
|
JOIN account_account aa ON aa.id = aml.account_id
|
|
WHERE
|
|
{where_query}
|
|
AND am.date < %(date_from)s
|
|
AND aa.include_initial_balance = 't'
|
|
GROUP BY aml.account_id, aa.account_type
|
|
HAVING aa.account_type not in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %(currency_digits)s) != 0
|
|
'''
|
|
currency_digits = 2
|
|
params = {
|
|
**where_params,
|
|
'formatted_date_year': self.date_from.year,
|
|
'formatted_date_from': fields.Date.to_string(self.date_from).replace('-', ''),
|
|
'date_from': self.date_from,
|
|
'currency_digits': currency_digits,
|
|
}
|
|
self._cr.execute(sql_query, params)
|
|
|
|
for row in self._cr.fetchall():
|
|
listrow = list(row)
|
|
account_id = listrow.pop()
|
|
if not unaffected_earnings_line:
|
|
account = self.env['account.account'].browse(account_id)
|
|
if account.account_type == 'equity_unaffected':
|
|
#add the benefit/loss of previous fiscal year to the first unaffected earnings account found.
|
|
unaffected_earnings_line = True
|
|
current_amount = float(listrow[11].replace(',', '.')) - float(listrow[12].replace(',', '.'))
|
|
unaffected_earnings_amount = float(unaffected_earnings_results[11].replace(',', '.')) - float(unaffected_earnings_results[12].replace(',', '.'))
|
|
listrow_amount = current_amount + unaffected_earnings_amount
|
|
if float_is_zero(listrow_amount, precision_digits=currency_digits):
|
|
continue
|
|
if listrow_amount > 0:
|
|
listrow[11] = str(listrow_amount).replace('.', ',')
|
|
listrow[12] = '0,00'
|
|
else:
|
|
listrow[11] = '0,00'
|
|
listrow[12] = str(-listrow_amount).replace('.', ',')
|
|
rows_to_write.append(listrow)
|
|
|
|
#if the unaffected earnings account wasn't in the selection yet: add it manually
|
|
if (not unaffected_earnings_line
|
|
and unaffected_earnings_results
|
|
and (unaffected_earnings_results[11] != '0,00'
|
|
or unaffected_earnings_results[12] != '0,00')):
|
|
#search an unaffected earnings account
|
|
unaffected_earnings_account = self.env['account.account'].search([
|
|
('account_type', '=', 'equity_unaffected')
|
|
], limit=1)
|
|
if unaffected_earnings_account:
|
|
unaffected_earnings_results[4] = unaffected_earnings_account.code
|
|
unaffected_earnings_results[5] = unaffected_earnings_account.name
|
|
rows_to_write.append(unaffected_earnings_results)
|
|
|
|
# INITIAL BALANCE - receivable/payable
|
|
sql_query = f'''
|
|
SELECT
|
|
'OUV' AS JournalCode,
|
|
'Balance initiale' AS JournalLib,
|
|
'OUVERTURE/' || %(formatted_date_year)s AS EcritureNum,
|
|
%(formatted_date_from)s AS EcritureDate,
|
|
MIN(aa.code) AS CompteNum,
|
|
replace(MIN({aa_name}), '|', '/') AS CompteLib,
|
|
CASE WHEN MIN(aa.account_type) IN ('asset_receivable', 'liability_payable')
|
|
THEN
|
|
CASE WHEN rp.ref IS null OR rp.ref = ''
|
|
THEN rp.id::text
|
|
ELSE replace(rp.ref, '|', '/')
|
|
END
|
|
ELSE ''
|
|
END
|
|
AS CompAuxNum,
|
|
CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
|
|
THEN COALESCE(replace(rp.name, '|', '/'), '')
|
|
ELSE ''
|
|
END AS CompAuxLib,
|
|
'-' AS PieceRef,
|
|
%(formatted_date_from)s AS PieceDate,
|
|
'/' AS EcritureLib,
|
|
replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit,
|
|
replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit,
|
|
'' AS EcritureLet,
|
|
'' AS DateLet,
|
|
%(formatted_date_from)s AS ValidDate,
|
|
'' AS Montantdevise,
|
|
'' AS Idevise,
|
|
MIN(aa.id) AS CompteID
|
|
FROM
|
|
account_move_line aml
|
|
LEFT JOIN account_move am ON am.id=aml.move_id
|
|
LEFT JOIN res_partner rp ON rp.id=aml.partner_id
|
|
JOIN account_account aa ON aa.id = aml.account_id
|
|
WHERE
|
|
{where_query}
|
|
AND am.date < %(date_from)s
|
|
AND aa.include_initial_balance = 't'
|
|
GROUP BY aml.account_id, aa.account_type, rp.ref, rp.id
|
|
HAVING aa.account_type in ('asset_receivable', 'liability_payable') AND round(sum(aml.balance), %(currency_digits)s) != 0
|
|
'''
|
|
self._cr.execute(sql_query, params)
|
|
|
|
for row in self._cr.fetchall():
|
|
listrow = list(row)
|
|
account_id = listrow.pop()
|
|
rows_to_write.append(listrow)
|
|
|
|
# LINES
|
|
if self.pool['account.journal'].name.translate:
|
|
lang = self.env.user.lang or get_lang(self.env).code
|
|
aj_name = f"COALESCE(aj.name->>'{lang}', aj.name->>'en_US')"
|
|
else:
|
|
aj_name = "aj.name"
|
|
|
|
query_limit = int(self.env['ir.config_parameter'].sudo().get_param('l10n_fr_fec.batch_size', 500000)) # To prevent memory errors when fetching the results
|
|
|
|
sql_query = f'''
|
|
SELECT
|
|
REGEXP_REPLACE(replace(aj.code, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalCode,
|
|
REGEXP_REPLACE(replace({aj_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS JournalLib,
|
|
REGEXP_REPLACE(replace(am.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS EcritureNum,
|
|
TO_CHAR(am.date, 'YYYYMMDD') AS EcritureDate,
|
|
aa.code AS CompteNum,
|
|
REGEXP_REPLACE(replace({aa_name}, '|', '/'), '[\\t\\r\\n]', ' ', 'g') AS CompteLib,
|
|
CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
|
|
THEN
|
|
CASE WHEN rp.ref IS null OR rp.ref = ''
|
|
THEN rp.id::text
|
|
ELSE replace(rp.ref, '|', '/')
|
|
END
|
|
ELSE ''
|
|
END
|
|
AS CompAuxNum,
|
|
CASE WHEN aa.account_type IN ('asset_receivable', 'liability_payable')
|
|
THEN COALESCE(REGEXP_REPLACE(replace(rp.name, '|', '/'), '[\\t\\r\\n]', ' ', 'g'), '')
|
|
ELSE ''
|
|
END AS CompAuxLib,
|
|
CASE WHEN am.ref IS null OR am.ref = ''
|
|
THEN '-'
|
|
ELSE REGEXP_REPLACE(replace(am.ref, '|', '/'), '[\\t\\r\\n]', ' ', 'g')
|
|
END
|
|
AS PieceRef,
|
|
TO_CHAR(COALESCE(am.invoice_date, am.date), 'YYYYMMDD') AS PieceDate,
|
|
CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/'
|
|
WHEN aml.name SIMILAR TO '[\\t|\\s|\\n]*' THEN '/'
|
|
ELSE REGEXP_REPLACE(replace(aml.name, '|', '/'), '[\\t\\n\\r]', ' ', 'g') END AS EcritureLib,
|
|
replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit,
|
|
replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit,
|
|
CASE WHEN rec.id IS NULL THEN ''::text ELSE rec.id::text END AS EcritureLet,
|
|
CASE WHEN aml.full_reconcile_id IS NULL THEN '' ELSE TO_CHAR(rec.create_date, 'YYYYMMDD') END AS DateLet,
|
|
TO_CHAR(am.date, 'YYYYMMDD') AS ValidDate,
|
|
CASE
|
|
WHEN aml.amount_currency IS NULL OR aml.amount_currency = 0 THEN ''
|
|
ELSE replace(to_char(aml.amount_currency, '000000000000000D99'), '.', ',')
|
|
END AS Montantdevise,
|
|
CASE WHEN aml.currency_id IS NULL THEN '' ELSE rc.name END AS Idevise
|
|
FROM
|
|
account_move_line aml
|
|
LEFT JOIN account_move am ON am.id=aml.move_id
|
|
LEFT JOIN res_partner rp ON rp.id=aml.partner_id
|
|
JOIN account_journal aj ON aj.id = am.journal_id
|
|
JOIN account_account aa ON aa.id = aml.account_id
|
|
LEFT JOIN res_currency rc ON rc.id = aml.currency_id
|
|
LEFT JOIN account_full_reconcile rec ON rec.id = aml.full_reconcile_id
|
|
WHERE
|
|
{where_query}
|
|
AND am.date >= %(date_from)s
|
|
AND am.date <= %(date_to)s
|
|
ORDER BY
|
|
am.date,
|
|
am.name,
|
|
aml.id
|
|
LIMIT %(limit)s
|
|
OFFSET %(offset)s
|
|
'''
|
|
|
|
with io.BytesIO() as fecfile:
|
|
csv_writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
|
|
|
|
# Write header and initial balances
|
|
for initial_row in rows_to_write:
|
|
initial_row = list(initial_row)
|
|
# We don't skip \n at then end of the file if there are only initial balances, for simplicity. An empty period export shouldn't happen IRL.
|
|
initial_row[-1] += u'\r\n'
|
|
csv_writer.writerow(initial_row)
|
|
|
|
# Write current period's data
|
|
query_offset = 0
|
|
has_more_results = True
|
|
while has_more_results:
|
|
self._cr.execute(sql_query, {**params, 'date_to': self.date_to, 'limit': query_limit + 1, 'offset': query_offset})
|
|
query_offset += query_limit
|
|
has_more_results = self._cr.rowcount > query_limit # we load one more result than the limit to check if there is more
|
|
query_results = self._cr.fetchall()
|
|
for i, row in enumerate(query_results[:query_limit]):
|
|
if i < len(query_results) - 1:
|
|
# The file is not allowed to end with an empty line, so we can't use lineterminator on the writer
|
|
row = list(row)
|
|
row[-1] += u'\r\n'
|
|
csv_writer.writerow(row)
|
|
|
|
base64_result = base64.encodebytes(fecfile.getvalue())
|
|
|
|
end_date = fields.Date.to_string(self.date_to).replace('-', '')
|
|
suffix = ''
|
|
if self.export_type == "nonofficial":
|
|
suffix = '-NONOFFICIAL'
|
|
|
|
self.write({
|
|
'fec_data': base64_result,
|
|
# Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date
|
|
'filename': '%sFEC%s%s.csv' % (company_legal_data, end_date, suffix),
|
|
})
|
|
|
|
# Set fiscal year lock date to the end date (not in test)
|
|
fiscalyear_lock_date = self.env.company.fiscalyear_lock_date
|
|
if not self.test_file and (not fiscalyear_lock_date or fiscalyear_lock_date < self.date_to):
|
|
self.env.company.write({'fiscalyear_lock_date': self.date_to})
|
|
return {
|
|
'name': 'FEC',
|
|
'type': 'ir.actions.act_url',
|
|
'url': "web/content/?model=account.fr.fec&id=" + str(self.id) + "&filename_field=filename&field=fec_data&download=true&filename=" + self.filename,
|
|
'target': 'new',
|
|
}
|
|
|
|
def _csv_write_rows(self, rows, lineterminator=u'\r\n'): #DEPRECATED; will disappear in master
|
|
"""
|
|
Write FEC rows into a file
|
|
It seems that Bercy's bureaucracy is not too happy about the
|
|
empty new line at the End Of File.
|
|
|
|
@param {list(list)} rows: the list of rows. Each row is a list of strings
|
|
@param {unicode string} [optional] lineterminator: effective line terminator
|
|
Has nothing to do with the csv writer parameter
|
|
The last line written won't be terminated with it
|
|
|
|
@return the value of the file
|
|
"""
|
|
fecfile = io.BytesIO()
|
|
writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
|
|
|
|
rows_length = len(rows)
|
|
for i, row in enumerate(rows):
|
|
if not i == rows_length - 1:
|
|
row[-1] += lineterminator
|
|
writer.writerow(row)
|
|
|
|
fecvalue = fecfile.getvalue()
|
|
fecfile.close()
|
|
return fecvalue
|