# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import csv from odoo import fields, models, _ from odoo.tools import float_repr, float_compare from datetime import datetime from collections import namedtuple, defaultdict import tempfile import zipfile import io import re import os BalanceKey = namedtuple('BalanceKey', ['from_code', 'to_code', 'partner_id', 'tax_id']) class AccountGeneralLedgerReportHandler(models.AbstractModel): _inherit = 'account.general.ledger.report.handler' def _custom_options_initializer(self, report, options, previous_options): """ Add the invoice lines search domain that common for all countries. :param dict options: Report options :param dict previous_options: Previous report options """ super()._custom_options_initializer(report, options, previous_options) if self.env.company.country_code in ('DE', 'CH', 'AT'): options.setdefault('buttons', []).extend(( { 'name': _('Datev DATA (zip)'), 'sequence': 30, 'action': 'export_file', 'action_param': 'l10n_de_datev_export_to_zip', 'file_export_type': _('Datev zip'), }, { 'name': _('Datev ATCH (zip)'), 'sequence': 40, 'action': 'export_file', 'action_param': 'l10_de_datev_export_to_zip_and_attach', 'file_export_type': _('Datev + batch zip'), }, )) def _l10n_de_datev_build_line( self, *, line_amount_currency, line_amount, currency, company_currency, account_code, counterpart_code, code_correction, move, receipt1, receipt2, reference_text, force_debit_credit=None, ): """ Build and return a 125-field DATEV export row. """ # Idiotic program needs to have a line with 125 elements ordered in a given fashion as it # does not take into account the header and non mandatory fields array = ['' for _ in range(125)] # For DateV, we can't have negative amount on a line, so we need to inverse the amount and inverse the # credit/debit symbol. if force_debit_credit: array[1] = force_debit_credit else: array[1] = 'H' if currency.compare_amounts(line_amount, 0) < 0 else 'S' line_amount = abs(line_amount) # Column A: the amount in the currency that was used. It can be a foreign one, it can be the company's. array[0] = float_repr(line_amount_currency, currency.decimal_places).replace('.', ',') # Column C: the corresponding foreign currency used on the original record (invoice, bill, entry, ....) array[2] = currency.name if currency != company_currency: # Column D: ratio is E/A if D !=0, else no rate. if not company_currency.is_zero(line_amount) and not currency.is_zero(line_amount_currency): rate = round(line_amount_currency / line_amount, 6) or 1.0 else: rate = 1.0 array[3] = str(rate).replace('.', ',') # Column E: the amount converted in the company currency if the original record was in a foreign currency array[4] = float_repr(line_amount, company_currency.decimal_places).replace('.', ',') # Column F: the company currency if the original record was in a foreign currency array[5] = company_currency.name array[6] = account_code array[7] = counterpart_code array[8] = code_correction array[9] = datetime.strftime(move.date, '%-d%m') array[10] = receipt1[-36:] array[11] = receipt2 array[13] = reference_text or '' if move.message_main_attachment_id: array[19] = f'BEDI "{move._l10n_de_datev_get_guid()}"' return array def l10_de_datev_export_to_zip_and_attach(self, options): options['add_attachments'] = True return self.l10n_de_datev_export_to_zip(options) def l10n_de_datev_export_to_zip(self, options): """ Check ir_attachment for method _get_path create a sha and replace 2 first letters by something not hexadecimal Return full_path as 2nd args, use it as name for Zipfile Don't need to unlink as it will be done automatically by garbage collector of attachment cron """ report = self.env['account.report'].browse(options['report_id']) with tempfile.NamedTemporaryFile(mode='w+b', delete=True) as buf: with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) as zf: domain = report._get_options_domain(options, 'strict_range') move_line_ids = self.env['account.move.line'].search(domain).ids domain = [ ('line_ids', 'in', move_line_ids), ('company_id', 'in', report.get_report_company_ids(options)), ] if options.get('all_entries'): domain += [('state', '!=', 'cancel')] else: domain += [('state', '=', 'posted')] if options.get('date'): domain += [('date', '<=', options['date']['date_to'])] # cannot set date_from on move as domain depends on the move line account if "strict_range" is False domain += report._get_options_journals_domain(options) moves = self.env['account.move'].search(domain) if options.get('add_attachments'): # add all moves attachments in zip file slash_re = re.compile('[\\/]') documents = [] for move in moves.filtered(lambda m: m.message_main_attachment_id): # '\' is not allowed in file name, replace by '-' base_name = slash_re.sub('-', move.name) # The message_main_attachment_id may not be linked to the associated move causing AccessError # For example, with an image, the res_id is 0 and the res_model is False attachment = move.sudo().message_main_attachment_id extension = f".{attachment.name.split('.')[-1]}" name = '%(base)s%(extension)s' % {'base': base_name, 'extension': extension} zf.writestr(name, attachment.raw) documents.append({ 'guid': move._l10n_de_datev_get_guid(), 'filename': name, 'type': 2 if move.is_sale_document() else 1 if move.is_purchase_document() else None, }) if documents: metadata_document = self.env['ir.qweb']._render( 'l10n_de_reports.datev_export_metadata', values={ 'documents': documents, 'date': fields.Datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), }, ) zf.writestr('document.xml', "" + str(metadata_document)) else: # ZIP for Data => csv set_move_line_ids = set(move_line_ids) zf.writestr('EXTF_accounting_entries.csv', self._l10n_de_datev_get_csv(options, moves)) zf.writestr('EXTF_customer_accounts.csv', self._l10n_de_datev_get_partner_list(options, set_move_line_ids, customer=True)) zf.writestr('EXTF_vendor_accounts.csv', self._l10n_de_datev_get_partner_list(options, set_move_line_ids, customer=False)) buf.seek(0) content = buf.read() filename, extension = report.get_default_report_filename(options, 'ZIP').rsplit('.', 1) return { 'file_name': f'{filename}_atch.{extension}' if options.get('add_attachments') else f'{filename}_data.{extension}', 'file_content': content, 'file_type': 'zip' } def _l10n_de_datev_get_client_number(self): consultant_number = self.env.company.l10n_de_datev_consultant_number client_number = self.env.company.l10n_de_datev_client_number if not consultant_number: consultant_number = 99999 if not client_number: client_number = 999 return [consultant_number, client_number] def _l10n_de_datev_get_partner_list(self, options, move_line_ids, customer=True): date_to = fields.Date.from_string(options.get('date').get('date_to')) fy = self.env.company.compute_fiscalyear_dates(date_to) fy = datetime.strftime(fy.get('date_from'), '%Y%m%d') datev_info = self._l10n_de_datev_get_client_number() account_length = self._l10n_de_datev_get_account_length() output = io.StringIO() writer = csv.writer(output, delimiter=';', quotechar='"', quoting=2) preheader = ['EXTF', 700, 16, 'Debitoren/Kreditoren', 5, None, None, '', '', '', datev_info[0], datev_info[1], fy, account_length, '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] header = [ 'Konto', 'Name (Adressattyp Unternehmen)', 'Unternehmensgegenstand', 'Name (Adressattyp natürl. Person)', 'Vorname (Adressattyp natürl. Person)', 'Name (Adressattyp keine Angabe)', 'Adressattyp', 'Kurzbezeichnung', 'EU-Land', 'EU-UStID', 'Anrede', 'Titel/Akad. Grad', 'Adelstitel', 'Namensvorsatz', 'Adressart', 'Straße', 'Postfach', 'Postleitzahl', 'Ort', 'Land', 'Versandzusatz', 'Adresszusatz', 'Abweichende Anrede', 'Abw. Zustellbezeichnung 1', 'Abw. Zustellbezeichnung 2', 'Kennz. Korrespondenzadresse', 'Adresse Gültig von', 'Adresse Gültig bis', 'Telefon', 'Bemerkung (Telefon)', 'Telefon GL', 'Bemerkung (Telefon GL)', 'E-Mail', 'Bemerkung (E-Mail)', 'Internet', 'Bemerkung (Internet)', 'Fax', 'Bemerkung (Fax)', 'Sonstige', 'Bemerkung (Sonstige)', 'Bankleitzahl 1', 'Bankbezeichnung 1', 'Bank-Kontonummer 1', 'Länderkennzeichen 1', 'IBAN-Nr. 1', 'Leerfeld', 'SWIFT-Code 1', 'Abw. Kontoinhaber 1', 'Kennz. Hauptbankverb. 1', 'Bankverb 1 Gültig von', 'Bankverb 1 Gültig bis', 'Bankleitzahl 2', 'Bankbezeichnung 2', 'Bank-Kontonummer 2', 'Länderkennzeichen 2', 'IBAN-Nr. 2', 'Leerfeld', 'SWIFT-Code 2', 'Abw. Kontoinhaber 2', 'Kennz. Hauptbankverb. 2', 'Bankverb 2 Gültig von', 'Bankverb 2 Gültig bis', 'Bankleitzahl 3', 'Bankbezeichnung 3', 'Bank-Kontonummer 3', 'Länderkennzeichen 3', 'IBAN-Nr. 3', 'Leerfeld', 'SWIFT-Code 3', 'Abw. Kontoinhaber 3', 'Kennz. Hauptbankverb. 3', 'Bankverb 3 Gültig von', 'Bankverb 3 Gültig bis', 'Bankleitzahl 4', 'Bankbezeichnung 4', 'Bank-Kontonummer 4', 'Länderkennzeichen 4', 'IBAN-Nr. 4', 'Leerfeld', 'SWIFT-Code 4', 'Abw. Kontoinhaber 4', 'Kennz. Hauptbankverb. 4', 'Bankverb 4 Gültig von', 'Bankverb 4 Gültig bis', 'Bankleitzahl 5', 'Bankbezeichnung 5', 'Bank-Kontonummer 5', 'Länderkennzeichen 5', 'IBAN-Nr. 5', 'Leerfeld', 'SWIFT-Code 5', 'Abw. Kontoinhaber 5', 'Kennz. Hauptbankverb. 5', 'Bankverb 5 Gültig von', 'Bankverb 5 Gültig bis', 'Leerfeld', 'Briefanrede', 'Grußformel', 'Kunden-/Lief.-Nr.', 'Steuernummer', 'Sprache', 'Ansprechpartner', 'Vertreter', 'Sachbearbeiter', 'Diverse-Konto', 'Ausgabeziel', 'Währungssteuerung', 'Kreditlimit (Debitor)', 'Zahlungsbedingung', 'Fälligkeit in Tagen (Debitor)', 'Skonto in Prozent (Debitor)', 'Kreditoren-Ziel 1 Tg.', 'Kreditoren-Skonto 1 %', 'Kreditoren-Ziel 2 Tg.', 'Kreditoren-Skonto 2 %', 'Kreditoren-Ziel 3 Brutto Tg.', 'Kreditoren-Ziel 4 Tg.', 'Kreditoren-Skonto 4 %', 'Kreditoren-Ziel 5 Tg.', 'Kreditoren-Skonto 5 %', 'Mahnung', 'Kontoauszug', 'Mahntext 1', 'Mahntext 2', 'Mahntext 3', 'Kontoauszugstext', 'Mahnlimit Betrag', 'Mahnlimit %', 'Zinsberechnung', 'Mahnzinssatz 1', 'Mahnzinssatz 2', 'Mahnzinssatz 3', 'Lastschrift', 'Leerfeld', 'Mandantenbank', 'Zahlungsträger', 'Indiv. Feld 1', 'Indiv. Feld 2', 'Indiv. Feld 3', 'Indiv. Feld 4', 'Indiv. Feld 5', 'Indiv. Feld 6', 'Indiv. Feld 7', 'Indiv. Feld 8', 'Indiv. Feld 9', 'Indiv. Feld 10', 'Indiv. Feld 11', 'Indiv. Feld 12', 'Indiv. Feld 13', 'Indiv. Feld 14', 'Indiv. Feld 15', 'Abweichende Anrede (Rechnungsadresse)', 'Adressart (Rechnungsadresse)', 'Straße (Rechnungsadresse)', 'Postfach (Rechnungsadresse)', 'Postleitzahl (Rechnungsadresse)', 'Ort (Rechnungsadresse)', 'Land (Rechnungsadresse)', 'Versandzusatz (Rechnungsadresse)', 'Adresszusatz (Rechnungsadresse)', 'Abw. Zustellbezeichnung 1 (Rechnungsadresse)', 'Abw. Zustellbezeichnung 2 (Rechnungsadresse)', 'Adresse Gültig von (Rechnungsadresse)', 'Adresse Gültig bis (Rechnungsadresse)', 'Bankleitzahl 6', 'Bankbezeichnung 6', 'Bank-Kontonummer 6', 'Länderkennzeichen 6', 'IBAN-Nr. 6', 'Leerfeld', 'SWIFT-Code 6', 'Abw. Kontoinhaber 6', 'Kennz. Hauptbankverb. 6', 'Bankverb 6 Gültig von', 'Bankverb 6 Gültig bis', 'Bankleitzahl 7', 'Bankbezeichnung 7', 'Bank-Kontonummer 7', 'Länderkennzeichen 7', 'IBAN-Nr. 7', 'Leerfeld', 'SWIFT-Code 7', 'Abw. Kontoinhaber 7', 'Kennz. Hauptbankverb. 7', 'Bankverb 7 Gültig von', 'Bankverb 7 Gültig bis', 'Bankleitzahl 8', 'Bankbezeichnung 8', 'Bank-Kontonummer 8', 'Länderkennzeichen 8', 'IBAN-Nr. 8', 'Leerfeld', 'SWIFT-Code 8', 'Abw. Kontoinhaber 8', 'Kennz. Hauptbankverb. 8', 'Bankverb 8 Gültig von', 'Bankverb 8 Gültig bis', 'Bankleitzahl 9', 'Bankbezeichnung 9', 'Bank-Kontonummer 9', 'Länderkennzeichen 9', 'IBAN-Nr. 9', 'Leerfeld', 'SWIFT-Code 9', 'Abw. Kontoinhaber 9', 'Kennz. Hauptbankverb. 9', 'Bankverb 9 Gültig von', 'Bankverb 9 Gültig bis', 'Bankleitzahl 10', 'Bankbezeichnung 10', 'Bank-Kontonummer 10', 'Länderkennzeichen 10', 'IBAN-Nr. 10', 'Leerfeld', 'SWIFT-Code 10', 'Abw. Kontoinhaber 10', 'Kennz. Hauptbankverb. 10', 'Bankverb 10 Gültig von', 'Bankverb 10 Gültig bis', 'Nummer Fremdsystem', 'Insolvent', 'SEPA-Mandatsreferenz 1', 'SEPA-Mandatsreferenz 2', 'SEPA-Mandatsreferenz 3', 'SEPA-Mandatsreferenz 4', 'SEPA-Mandatsreferenz 5', 'SEPA-Mandatsreferenz 6', 'SEPA-Mandatsreferenz 7', 'SEPA-Mandatsreferenz 8', 'SEPA-Mandatsreferenz 9', 'SEPA-Mandatsreferenz 10', 'Verknüpftes OPOS-Konto', 'Mahnsperre bis', 'Lastschriftsperre bis', 'Zahlungssperre bis', 'Gebührenberechnung', 'Mahngebühr 1', 'Mahngebühr 2', 'Mahngebühr 3', 'Pauschalenberechnung', 'Verzugspauschale 1', 'Verzugspauschale 2', 'Verzugspauschale 3', 'Alternativer Suchname', 'Status', 'Anschrift manuell geändert (Korrespondenzadresse)', 'Anschrift individuell (Korrespondenzadresse)', 'Anschrift manuell geändert (Rechnungsadresse)', 'Anschrift individuell (Rechnungsadresse)', 'Fristberechnung bei Debitor', 'Mahnfrist 1', 'Mahnfrist 2', 'Mahnfrist 3', 'Letzte Frist', ] lines = [preheader, header] if len(move_line_ids): if customer: move_types = ('out_refund', 'out_invoice', 'out_receipt') else: move_types = ('in_refund', 'in_invoice', 'in_receipt') select = """SELECT distinct(aml.partner_id) FROM account_move_line aml JOIN account_move m ON aml.move_id = m.id WHERE aml.id IN %s AND aml.tax_line_id IS NULL AND aml.debit != aml.credit AND m.move_type IN %s AND aml.account_id != m.l10n_de_datev_main_account_id AND aml.partner_id IS NOT NULL""" self.env.cr.execute(select, (tuple(move_line_ids), move_types)) partners = self.env['res.partner'].browse([p.get('partner_id') for p in self.env.cr.dictfetchall()]) eu_country_codes = self.env.ref('base.europe').country_ids.mapped('code') for partner in partners: if customer: code = self._l10n_de_datev_find_partner_account(partner.property_account_receivable_id, partner) else: code = self._l10n_de_datev_find_partner_account(partner.property_account_payable_id, partner) vat_is_valid = False country_code = '' if partner.vat and len(partner.vat) > 2: vat_country, vat_id_no = partner._split_vat(partner.vat) vat_is_valid = vat_country and partner._check_vat_number(vat_country, vat_id_no) if vat_country.isalpha(): country_code = 'GR' if vat_country.upper() == 'EL' else vat_country.upper() is_eu_vat_number = country_code in eu_country_codes line_value = { 'code': code, 'company_name': partner.name if partner.is_company else '', 'person_name': '' if partner.is_company else partner.name, 'natural': partner.is_company and '2' or '1', 'eu_vat_country': vat_country.upper() if vat_is_valid and is_eu_vat_number and vat_country.isalpha() else '', 'eu_vat_id_no': vat_id_no if vat_is_valid and is_eu_vat_number else '', 'country_code': partner.country_code or country_code or '', } # Idiotic program needs to have a line with 243 elements ordered in a given fashion as it # does not take into account the header and non mandatory fields array = ['' for x in range(243)] array[0] = line_value.get('code') array[1] = line_value.get('company_name') array[3] = line_value.get('person_name') array[6] = line_value.get('natural') array[8] = line_value.get('eu_vat_country') array[9] = line_value.get('eu_vat_id_no') array[19] = line_value.get('country_code') lines.append(array) writer.writerows(lines) return output.getvalue() def _l10n_de_datev_get_account_length(self): return self.env.company.l10n_de_datev_account_length def _l10n_de_datev_find_partner_account(self, account, partner): len_param = self._l10n_de_datev_get_account_length() + 1 codes = { 'asset_receivable': (partner.l10n_de_datev_identifier_customer or int('1'.ljust(len_param, '0')) + partner.id), 'liability_payable': (partner.l10n_de_datev_identifier or int('7'.ljust(len_param, '0')) + partner.id) } if account.account_type in codes and partner: return codes[account.account_type] else: return str(account.code).ljust(len_param - 1, '0') if account else '' # Source: http://www.datev.de/dnlexom/client/app/index.html#/document/1036228/D103622800029 def _l10n_de_datev_get_csv(self, options, moves): # last 2 element of preheader should be filled by "consultant number" and "client number" date_from = fields.Date.from_string(options.get('date').get('date_from')) date_to = fields.Date.from_string(options.get('date').get('date_to')) fy = self.env.company.compute_fiscalyear_dates(date_to) date_from = datetime.strftime(date_from, '%Y%m%d') date_to = datetime.strftime(date_to, '%Y%m%d') fy = datetime.strftime(fy.get('date_from'), '%Y%m%d') datev_info = self._l10n_de_datev_get_client_number() account_length = self._l10n_de_datev_get_account_length() output = io.StringIO() writer = csv.writer(output, delimiter=';', quotechar='"', quoting=2) preheader = ['EXTF', 700, 21, 'Buchungsstapel', 13, '', '', '', '', '', datev_info[0], datev_info[1], fy, account_length, date_from, date_to, '', '', '', '', 0, 'EUR', '', '', '', '', '', '', '', '', ''] header = [ 'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum', 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext', 'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt', 'Zinssperre', 'Beleglink', 'Beleginfo - Art 1', 'Beleginfo - Inhalt 1', 'Beleginfo - Art 2', 'Beleginfo - Inhalt 2', 'Beleginfo - Art 3', 'Beleginfo - Inhalt 3', 'Beleginfo - Art 4', 'Beleginfo - Inhalt 4', 'Beleginfo - Art 5', 'Beleginfo - Inhalt 5', 'Beleginfo - Art 6', 'Beleginfo - Inhalt 6', 'Beleginfo - Art 7', 'Beleginfo - Inhalt 7', 'Beleginfo - Art 8', 'Beleginfo - Inhalt 8', 'KOST1 - Kostenstelle', 'KOST2 - Kostenstelle', 'Kost-Menge', 'EU-Land u. UStID (Bestimmung)', 'EU-Steuersatz (Bestimmung)', 'Abw. Versteuerungsart', 'Sachverhalt L+L', 'Funktionsergänzung L+L', 'BU 49 Hauptfunktionstyp', 'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung', 'Zusatzinformation - Art 1', 'Zusatzinformation- Inhalt 1', 'Zusatzinformation - Art 2', 'Zusatzinformation- Inhalt 2', 'Zusatzinformation - Art 3', 'Zusatzinformation- Inhalt 3', 'Zusatzinformation - Art 4', 'Zusatzinformation- Inhalt 4', 'Zusatzinformation - Art 5', 'Zusatzinformation- Inhalt 5', 'Zusatzinformation - Art 6', 'Zusatzinformation- Inhalt 6', 'Zusatzinformation - Art 7', 'Zusatzinformation- Inhalt 7', 'Zusatzinformation - Art 8', 'Zusatzinformation- Inhalt 8', 'Zusatzinformation - Art 9', 'Zusatzinformation- Inhalt 9', 'Zusatzinformation - Art 10', 'Zusatzinformation- Inhalt 10', 'Zusatzinformation - Art 11', 'Zusatzinformation- Inhalt 11', 'Zusatzinformation - Art 12', 'Zusatzinformation- Inhalt 12', 'Zusatzinformation - Art 13', 'Zusatzinformation- Inhalt 13', 'Zusatzinformation - Art 14', 'Zusatzinformation- Inhalt 14', 'Zusatzinformation - Art 15', 'Zusatzinformation- Inhalt 15', 'Zusatzinformation - Art 16', 'Zusatzinformation- Inhalt 16', 'Zusatzinformation - Art 17', 'Zusatzinformation- Inhalt 17', 'Zusatzinformation - Art 18', 'Zusatzinformation- Inhalt 18', 'Zusatzinformation - Art 19', 'Zusatzinformation- Inhalt 19', 'Zusatzinformation - Art 20', 'Zusatzinformation- Inhalt 20', 'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr', 'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp', 'USt-Schlüssel (Anzahlungen)', 'EU-Land (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)', 'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz', 'Buchungs GUID', 'KOST-Datum', 'SEPA-Mandatsreferenz', 'Skontosperre', 'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer', 'Postensperre bis', 'Bezeichnung SoBil-Sachverhalt', 'Kennzeichen SoBil-Buchung', 'Festschreibung', 'Leistungsdatum', 'Datum Zuord. Steuerperiode', 'Fälligkeit', 'Generalumkehr (GU)', 'Steuersatz', 'Land', 'Abrechnungsreferenz', 'BVV-Position', 'EU-Land u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto', ] lines = [preheader, header] for m in moves: delta_by_aml = defaultdict(float) # the final delta to apply to each aml # For bills, it's possible to edit the total amount of a tax group manually. # Unfortunatly this change only affects the amounts in tax_totals field, but not # price_total field of the invoice lines, which is used in the export. # Therefore, the exported amount can be wrong because it doesn't include the tax change. # To detect such a case, we check if the sum of price_total of each line is equal to # the total amount in tax_totals. # It can happen that price_total is not set. In this case, the exported amount is # recomputed and also differs from the edited amount. # However, this case is not handled here for the moment. if (m.move_type == 'in_invoice' and all(line.price_total for line in m.invoice_line_ids) and float_compare( m.tax_totals['total_amount'], sum(m.invoice_line_ids.mapped('price_total')), precision_rounding=m.currency_id.rounding, ) ): # The amount of a tax group has probably been edited manually. # So we cannot use price_total of the aml as it is and we should dispatch the # delta between all the lines where a tax of the same group has been used. amls_by_group = defaultdict(list) # Get the modified tax group amounts actual_values_by_group = { self.env['account.tax.group'].browse(tax_group['id']): tax_group['tax_amount_currency'] for subtotal in m.tax_totals['subtotals'] for tax_group in subtotal['tax_groups'] } # Get the originally computed values original_values_by_group = defaultdict(float) for line in m.invoice_line_ids: line_taxes = line.tax_ids.compute_all(line.amount_currency, line.currency_id, partner=line.partner_id, handle_price_include=False) tax_amounts = defaultdict(float) for tax_data in line_taxes['taxes']: tax_amounts[tax_data['id']] += tax_data['amount'] for tax_id in tax_amounts: tax = self.env['account.tax'].browse(tax_id) original_values_by_group[tax.tax_group_id] += tax_amounts[tax.id] amls_by_group[tax.tax_group_id].append(line) # Compute deltas by tax group and assign the difference for tax_group, actual_group_amount in actual_values_by_group.items(): delta_group_amount = actual_group_amount - original_values_by_group[tax_group] current_lines = amls_by_group[tax_group] nb_lines = len(current_lines) # Round and add to every line line_delta_amount = line.currency_id.round(delta_group_amount / nb_lines) for line in current_lines: delta_by_aml[line] += line_delta_amount # Add eventual rounding remainder to the first line of the tax group remainder = line.currency_id.round(delta_group_amount - line_delta_amount * nb_lines) delta_by_aml[current_lines[0]] += remainder payment_account = 0 # Used for non-reconciled payments move_balance = 0 counterpart_amount = 0 last_tax_line_index = 0 # Check for three currency case # A three-currency case occurs when a bank transaction involves: # - the payer's transaction currency, # - the bank journal's currency, # - the company's currency. # DATEV cannot represent both foreign currencies on the same journal item, # so foreign AR/AP lines are routed through a clearing account. clearing_account = '' foreign_arap_lines = self.env['account.move.line'] emit_bank_line = True if m.statement_line_id: liquidity_lines, _, other_lines = m.statement_line_id._seek_for_lines() if len(liquidity_lines) == 1: foreign_arap_lines = other_lines.filtered( lambda aml: ( aml.account_id.account_type in ( 'asset_receivable', 'liability_payable', ) and not aml.is_same_currency and aml.currency_id != liquidity_lines[0].currency_id ) ) if foreign_arap_lines: account_length = self._l10n_de_datev_get_account_length() clearing_base = ( "1460" if m.company_id.chart_template == "de_skr04" else "1360" ) clearing_account = clearing_base.ljust(account_length, '0') def _get_code_correction(taxes): codes = set(taxes.mapped('l10n_de_datev_code')) # there should be exactly one, else skip code return len(codes) == 1 and codes.pop() or '' for aml in m.line_ids: code_correction = '' if aml.debit == aml.credit: # Ignore debit = credit = 0 continue # account and counterpart account to_account_code = str(self._l10n_de_datev_find_partner_account(aml.move_id.l10n_de_datev_main_account_id, aml.partner_id)) account_code = u'{code}'.format(code=self._l10n_de_datev_find_partner_account(aml.account_id, aml.partner_id)) # We don't want to have lines with our outstanding payment/receipt as they don't represent real moves # So if payment skip one move line to write, while keeping the account # and replace bank account for outstanding payment/receipt for the other line if aml.payment_id: # An expense may be encoded with a tax, we should report the corresponding tax code if not code_correction: code_correction = _get_code_correction(m.line_ids.tax_ids) if payment_account == 0: payment_account = account_code counterpart_amount += aml.balance continue else: to_account_code = payment_account # If both account and counteraccount are the same, ignore the line if aml.account_id == aml.move_id.l10n_de_datev_main_account_id: if aml.statement_line_id and not aml.payment_id: counterpart_amount += aml.balance continue # If line is a tax ignore it as datev requires single line with gross amount and deduct tax itself based # on account or on the control key code if aml.tax_line_id: continue if aml.price_total and m.move_type != 'entry': sign = -1 if aml.currency_id.compare_amounts(aml.balance, 0) < 0 else 1 line_amount_currency = abs(aml.price_total) * sign else: aml_taxes = aml.tax_ids.compute_all(aml.amount_currency, aml.currency_id, partner=aml.partner_id, handle_price_include=False) line_amount_currency = aml_taxes['total_included'] # convert line_amount in company currency if aml.currency_id != aml.company_id.currency_id: if not aml.currency_id.is_zero(line_amount_currency): rate = m._get_product_base_line_currency_rate(aml) line_amount = line_amount_currency / rate else: line_amount = aml.balance else: line_amount = line_amount_currency move_balance += line_amount if aml.tax_ids: last_tax_line_index = len(lines) last_tax_line_amount = line_amount code_correction = _get_code_correction(aml.tax_ids) # reference receipt1 = ref = aml.move_id.name if aml.move_id.journal_id.type == 'purchase' and aml.move_id.ref: ref = aml.move_id.ref # on receivable/payable aml of sales/purchases receipt2 = '' if to_account_code == account_code and aml.date_maturity: receipt2 = aml.date if aml in foreign_arap_lines: if emit_bank_line: lines.append( self._l10n_de_datev_build_clearing_liquidity_line( m, liquidity_lines[0], clearing_account, foreign_arap_lines, ) ) emit_bank_line = False to_account_code = clearing_account lines.append( self._l10n_de_datev_build_line( line_amount_currency=abs(line_amount_currency) + delta_by_aml[aml], line_amount=line_amount, currency=aml.currency_id, company_currency=aml.company_id.currency_id, account_code=account_code, counterpart_code=to_account_code, code_correction=code_correction, move=m, receipt1=receipt1, receipt2=receipt2, reference_text=aml.name or ref, ) ) # In case of epd we actively fix rounding issues by checking the base line and tax line # amounts against the move amount missing cent and adjust the vals accordingly. # Since here we have to recompute the tax values for each line with tax, we need # to replicate the rounding fix logic adding the difference on the last tax line # to avoid creating a difference with the source payment move if (m.origin_payment_id or m.statement_line_id) and move_balance and counterpart_amount and last_tax_line_index: delta_balance = move_balance + counterpart_amount if delta_balance: lines[last_tax_line_index][0] = float_repr(abs(last_tax_line_amount - delta_balance), m.company_id.currency_id.decimal_places).replace('.', ',') writer.writerows(lines) return output.getvalue() def _l10n_de_datev_build_clearing_liquidity_line(self, move, liquidity_line, clearing_account, foreign_arap_lines): """ Build the liquidity leg through the clearing account, using the combined balance of the foreign AR/AP lines. """ company_currency = move.company_id.currency_id liquidity_account_code = str( self._l10n_de_datev_find_partner_account( liquidity_line.account_id, liquidity_line.partner_id, ) ) liq_sh = "S" if company_currency.compare_amounts(liquidity_line.balance, 0.0) > 0 else "H" clearing_balance = sum(foreign_arap_lines.mapped('balance')) return self._l10n_de_datev_build_line( line_amount_currency=abs(liquidity_line.amount_currency), line_amount=clearing_balance, currency=liquidity_line.currency_id, company_currency=company_currency, account_code=liquidity_account_code, counterpart_code=clearing_account, code_correction='', move=move, receipt1=move.name, receipt2='', reference_text=liquidity_line.name or liquidity_line.ref, force_debit_credit=liq_sh, )