From 6db346767eb1913c59913615c3da609bcef4bc65 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Wed, 24 Mar 2021 09:22:59 +0000 Subject: [PATCH] [ADD] account_edi_ubl_bis3,l10n_nl_edi : added bis3 and nlcius formats closes odoo/odoo#68514 Signed-off-by: Laurent Smet --- .../account_edi/models/account_edi_format.py | 9 +- addons/account_edi/models/account_move.py | 1 + .../models/account_edi_format.py | 8 + addons/account_edi_ubl_bis3/__init__.py | 1 + addons/account_edi_ubl_bis3/__manifest__.py | 15 ++ .../data/bis3_templates.xml | 187 +++++++++++++++ .../account_edi_ubl_bis3/models/__init__.py | 3 + .../models/account_edi_format.py | 219 ++++++++++++++++++ addons/l10n_id_efaktur/models/res_partner.py | 1 - .../views/res_partner_views.xml | 1 - addons/l10n_nl/demo/demo_company.xml | 5 +- addons/l10n_nl/models/__init__.py | 1 + addons/l10n_nl/models/res_partner.py | 9 + addons/l10n_nl/views/res_partner_views.xml | 14 ++ addons/l10n_nl_edi/__init__.py | 4 + addons/l10n_nl_edi/__manifest__.py | 17 ++ addons/l10n_nl_edi/data/account_edi_data.xml | 11 + addons/l10n_nl_edi/data/nlcius_template.xml | 31 +++ addons/l10n_nl_edi/models/__init__.py | 3 + .../l10n_nl_edi/models/account_edi_format.py | 148 ++++++++++++ .../l10n_nl_edi/test_xml_file/nlcius_test.xml | 138 +++++++++++ addons/l10n_nl_edi/tests/__init__.py | 3 + addons/l10n_nl_edi/tests/test_nlcius.py | 218 +++++++++++++++++ odoo/addons/base/models/res_partner.py | 1 + odoo/addons/base/views/res_partner_views.xml | 1 + 25 files changed, 1042 insertions(+), 7 deletions(-) create mode 100644 addons/account_edi_ubl_bis3/__init__.py create mode 100644 addons/account_edi_ubl_bis3/__manifest__.py create mode 100644 addons/account_edi_ubl_bis3/data/bis3_templates.xml create mode 100644 addons/account_edi_ubl_bis3/models/__init__.py create mode 100644 addons/account_edi_ubl_bis3/models/account_edi_format.py create mode 100644 addons/l10n_nl/models/res_partner.py create mode 100644 addons/l10n_nl/views/res_partner_views.xml create mode 100644 addons/l10n_nl_edi/__init__.py create mode 100644 addons/l10n_nl_edi/__manifest__.py create mode 100644 addons/l10n_nl_edi/data/account_edi_data.xml create mode 100644 addons/l10n_nl_edi/data/nlcius_template.xml create mode 100644 addons/l10n_nl_edi/models/__init__.py create mode 100644 addons/l10n_nl_edi/models/account_edi_format.py create mode 100644 addons/l10n_nl_edi/test_xml_file/nlcius_test.xml create mode 100644 addons/l10n_nl_edi/tests/__init__.py create mode 100644 addons/l10n_nl_edi/tests/test_nlcius.py diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py index 3d8ed29566a..dced5f43abc 100644 --- a/addons/account_edi/models/account_edi_format.py +++ b/addons/account_edi/models/account_edi_format.py @@ -420,7 +420,7 @@ class AccountEdiFormat(models.Model): element = xml_element.xpath(xpath, namespaces=namespaces) return element[0].text if element else None - def _retrieve_partner(self, name=None, phone=None, mail=None, vat=None): + def _retrieve_partner(self, name=None, phone=None, mail=None, vat=None, domain=None): '''Search all partners and find one that matches one of the parameters. :param name: The name of the partner. @@ -430,14 +430,17 @@ class AccountEdiFormat(models.Model): :returns: A partner or an empty recordset if not found. ''' domains = [] - for value, domain in ( + for value, dom in ( (name, [('name', 'ilike', name)]), (phone, expression.OR([[('phone', '=', phone)], [('mobile', '=', phone)]])), (mail, [('email', '=', mail)]), (vat, [('vat', 'like', vat)]), ): if value is not None: - domains.append(domain) + domains.append(dom) + + if domain: + domains.append(domain) domain = expression.OR(domains) return self.env['res.partner'].search(domain, limit=1) diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py index 9cfdd13eaec..58f6a935b76 100644 --- a/addons/account_edi/models/account_move.py +++ b/addons/account_edi/models/account_move.py @@ -433,6 +433,7 @@ class AccountMoveLine(models.Model): taxes_added_to_base.add(tax.id) vals['tax_amount_currency'] += tax_vals['amount'] + vals['tax_amount_currency_closing'] += tax_vals['amount'] if tax_rep.use_in_tax_closing else 0 for tag_id in tax_rep.tag_ids: vals['tag_ids'].add(tag_id) diff --git a/addons/account_edi_ubl/models/account_edi_format.py b/addons/account_edi_ubl/models/account_edi_format.py index 10aff8ac29a..ab371e43810 100644 --- a/addons/account_edi_ubl/models/account_edi_format.py +++ b/addons/account_edi_ubl/models/account_edi_format.py @@ -234,6 +234,14 @@ class AccountEdiFormat(models.Model): return super()._is_compatible_with_journal(journal) return journal.type == 'sale' + def _is_enabled_by_default_on_journal(self, journal): + # OVERRIDE + # UBL is disabled by default to prevent conflict with other implementations of UBL. + self.ensure_one() + if self.code != 'ubl_2_1': + return super()._is_enabled_by_default_on_journal(journal) + return False + def _post_invoice_edi(self, invoices, test_mode=False): # OVERRIDE self.ensure_one() diff --git a/addons/account_edi_ubl_bis3/__init__.py b/addons/account_edi_ubl_bis3/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/addons/account_edi_ubl_bis3/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/account_edi_ubl_bis3/__manifest__.py b/addons/account_edi_ubl_bis3/__manifest__.py new file mode 100644 index 00000000000..8b866bf547f --- /dev/null +++ b/addons/account_edi_ubl_bis3/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Import/Export invoices with UBL (BIS3)', + 'description': ''' + Support for Export/Import in UBL format (BIS3). + ''', + 'version': '1.0', + 'category': 'Accounting/Accounting', + 'depends': ['account_edi_ubl'], + 'data': [ + 'data/bis3_templates.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/addons/account_edi_ubl_bis3/data/bis3_templates.xml b/addons/account_edi_ubl_bis3/data/bis3_templates.xml new file mode 100644 index 00000000000..dab2de16064 --- /dev/null +++ b/addons/account_edi_ubl_bis3/data/bis3_templates.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + diff --git a/addons/account_edi_ubl_bis3/models/__init__.py b/addons/account_edi_ubl_bis3/models/__init__.py new file mode 100644 index 00000000000..9a32558d2d4 --- /dev/null +++ b/addons/account_edi_ubl_bis3/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_edi_format diff --git a/addons/account_edi_ubl_bis3/models/account_edi_format.py b/addons/account_edi_ubl_bis3/models/account_edi_format.py new file mode 100644 index 00000000000..92fbb48578c --- /dev/null +++ b/addons/account_edi_ubl_bis3/models/account_edi_format.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.exceptions import UserError +from odoo.tests.common import Form + + +COUNTRY_EAS = { + 'HU': 9910, + + 'AD': 9922, + 'AL': 9923, + 'BA': 9924, + 'BE': 9925, + 'BG': 9926, + 'CH': 9927, + 'CY': 9928, + 'CZ': 9929, + 'DE': 9930, + 'EE': 9931, + 'UK': 9932, + 'GR': 9933, + 'HR': 9934, + 'IE': 9935, + 'LI': 9936, + 'LT': 9937, + 'LU': 9938, + 'LV': 9939, + 'MC': 9940, + 'ME': 9941, + 'MK': 9942, + 'MT': 9943, + 'NL': 9944, + 'PL': 9945, + 'PT': 9946, + 'RO': 9947, + 'RS': 9948, + 'SI': 9949, + 'SK': 9950, + 'SM': 9951, + 'TR': 9952, + 'VA': 9953, + + 'SE': 9955, + + 'FR': 9957 +} + + +class AccountEdiFormat(models.Model): + ''' This edi_format is "abstract" meaning that it provides an additional layer for similar edi_format (formats + deriving from EN16931) that share some functionalities but needs to be extended to be used. + ''' + _inherit = 'account.edi.format' + + #################################################### + # Export + #################################################### + + def _get_bis3_values(self, invoice): + values = super()._get_ubl_values(invoice) + values.update({ + 'customization_id': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0', + 'profile_id': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', + }) + + all_tax_detail_per_tax = {} + for line_vals in values['invoice_line_vals_list']: + line_vals['price_subtotal_with_no_tax_closing'] = line_vals['line'].price_subtotal + tax_detail_per_tax = {} + for tax_detail_vals in line_vals['tax_detail_vals_list']: + tax = tax_detail_vals['tax'] + + tax_percent = tax.amount + tax_category = 'S' if tax_percent else 'Z' + key = (tax_category, tax_percent) + tax_detail_per_tax.setdefault(key, { + 'base': tax_detail_vals['tax_base_amount'], + 'base_currency': tax_detail_vals['tax_base_amount_currency'], + 'amount': 0.0, + 'amount_currency': 0.0, + 'tax_percent': tax_percent, + 'tax_category': tax_category, + }) + vals = tax_detail_per_tax[key] + + vals['amount'] += tax_detail_vals['tax_amount_closing'] + vals['amount_currency'] += tax_detail_vals['tax_amount_currency_closing'] + delta_tax = tax_detail_vals['tax_amount_currency'] - tax_detail_vals['tax_amount_currency_closing'] + line_vals['price_subtotal_with_no_tax_closing'] += delta_tax + + if len(tax_detail_per_tax) > 1: + raise UserError("Multiple vat percentage not supported on the same invoice line") + + line_vals['tax_detail_vals'] = list(tax_detail_per_tax.values())[0] + + for key, tax_vals in tax_detail_per_tax.items(): + all_tax_detail_per_tax.setdefault(key, { + **tax_vals, + 'base': 0.0, + 'base_currency': 0.0, + 'amount': 0.0, + 'amount_currency': 0.0, + }) + vals = all_tax_detail_per_tax[key] + vals['base'] += tax_vals['base'] + vals['base_currency'] += tax_vals['base_currency'] + vals['amount'] += tax_vals['amount'] + vals['amount_currency'] += tax_vals['amount_currency'] + + values['tax_detail_vals_list'] = list(all_tax_detail_per_tax.values()) + values['total_untaxed_amount'] = sum(x['price_subtotal_with_no_tax_closing'] for x in values['invoice_line_vals_list']) + values['total_tax_amount'] = sum(x['amount'] for x in values['tax_detail_vals_list']) + values['total_tax_amount_currency'] = sum(x['amount_currency'] for x in values['tax_detail_vals_list']) + + for partner_vals in (values['customer_vals'], values['supplier_vals']): + partner = partner_vals['partner'] + if partner.country_id.code in COUNTRY_EAS: + partner_vals['bis3_endpoint'] = partner.vat + partner_vals['bis3_endpoint_scheme'] = COUNTRY_EAS[partner.country_id.code] + + return values + + #################################################### + # Import + #################################################### + + def _bis3_get_extra_partner_domains(self, tree): + """ Returns an additional domain to find the partner of the invoice based on specific implementation of BIS3. + TO OVERRIDE + + :returns: a list of domains + """ + return [] + + def _decode_bis3(self, tree, invoice): + """ Decodes an EN16931 invoice into an invoice. + :param tree: the UBL (EN16931) tree to decode. + :param invoice: the invoice to update or an empty recordset. + :returns: the invoice where the UBL (EN16931) data was imported. + """ + def _find_value(path, root=tree): + element = root.find(path) + return element.text if element is not None else None + + element = tree.find('./{*}InvoiceTypeCode') + if element is not None: + type_code = element.text + move_type = 'in_refund' if type_code == '381' else 'in_invoice' + else: + move_type = 'in_invoice' + + default_journal = invoice.with_context(default_move_type=move_type)._get_default_journal() + + with Form(invoice.with_context(default_move_type=move_type, default_journal_id=default_journal.id)) as invoice_form: + # Reference + element = tree.find('./{*}ID') + if element is not None: + invoice_form.ref = element.text + + # Dates + element = tree.find('./{*}IssueDate') + if element is not None: + invoice_form.invoice_date = element.text + element = tree.find('./{*}DueDate') + if element is not None: + invoice_form.invoice_date_due = element.text + + # Currency + currency = self._retrieve_currency(_find_value('./{*}DocumentCurrencyCode')) + if currency: + invoice_form.currency_id = currency + + # Partner + specific_domain = self._bis3_get_extra_partner_domains(tree) + invoice_form.partner_id = self._retrieve_partner( + name=_find_value('./{*}AccountingSupplierParty/{*}Party/*/{*}Name'), + phone=_find_value('./{*}AccountingSupplierParty/{*}Party/*/{*}Telephone'), + mail=_find_value('./{*}AccountingSupplierParty/{*}Party/*/{*}ElectronicMail'), + vat=_find_value('./{*}AccountingSupplierParty/{*}Party/{*}PartyTaxScheme/{*}CompanyID'), + domain=specific_domain, + ) + + # Lines + for eline in tree.findall('.//{*}InvoiceLine'): + with invoice_form.invoice_line_ids.new() as invoice_line_form: + # Product + invoice_line_form.product_id = self._retrieve_product( + default_code=_find_value('./{*}Item/{*}SellersItemIdentification/{*}ID', eline), + name=_find_value('./{*}Item/{*}Name', eline), + barcode=_find_value('./{*}Item/{*}StandardItemIdentification/{*}ID[@schemeID=\'0160\']', eline) + ) + + # Quantity + element = eline.find('./{*}InvoicedQuantity') + quantity = element is not None and float(element.text) or 1.0 + invoice_line_form.quantity = quantity + + # Price Unit + element = eline.find('./{*}Price/{*}PriceAmount') + price_unit = element is not None and float(element.text) or 0.0 + line_extension_amount = element is not None and float(element.text) or 0.0 + invoice_line_form.price_unit = price_unit or line_extension_amount / invoice_line_form.quantity or 0.0 + + # Name + element = eline.find('./{*}Item/{*}Description') + invoice_line_form.name = element is not None and element.text or '' + + # Taxes + tax_elements = eline.findall('./{*}Item/{*}ClassifiedTaxCategory') + invoice_line_form.tax_ids.clear() + for tax_element in tax_elements: + invoice_line_form.tax_ids.add(self._retrieve_tax( + amount=_find_value('./{*}Percent', tax_element), + type_tax_use=invoice_form.journal_id.type + )) + + return invoice_form.save() diff --git a/addons/l10n_id_efaktur/models/res_partner.py b/addons/l10n_id_efaktur/models/res_partner.py index 96f7c9c1c69..bff5353198d 100644 --- a/addons/l10n_id_efaktur/models/res_partner.py +++ b/addons/l10n_id_efaktur/models/res_partner.py @@ -8,7 +8,6 @@ class ResPartner(models.Model): """Inherit res.partner object to add NPWP field and Kode Transaksi""" _inherit = "res.partner" - country_code = fields.Char(related='country_id.code', string='Country Code') l10n_id_pkp = fields.Boolean(string="ID PKP", compute='_compute_l10n_id_pkp', store=True, readonly=False) l10n_id_nik = fields.Char(string='NIK') l10n_id_tax_address = fields.Char('Tax Address') diff --git a/addons/l10n_id_efaktur/views/res_partner_views.xml b/addons/l10n_id_efaktur/views/res_partner_views.xml index 9c6f25c61e9..0a7d692b759 100644 --- a/addons/l10n_id_efaktur/views/res_partner_views.xml +++ b/addons/l10n_id_efaktur/views/res_partner_views.xml @@ -7,7 +7,6 @@ - diff --git a/addons/l10n_nl/demo/demo_company.xml b/addons/l10n_nl/demo/demo_company.xml index 817faf37aa3..788341b2263 100644 --- a/addons/l10n_nl/demo/demo_company.xml +++ b/addons/l10n_nl/demo/demo_company.xml @@ -3,14 +3,15 @@ NL Company NL43603B11 - + Bloemstraat 42 Groningen - + 9700 +31 6 12345678 info@company.nlexample.com www.nlexample.com + 12345678 diff --git a/addons/l10n_nl/models/__init__.py b/addons/l10n_nl/models/__init__.py index a3c0ab069f1..c8614475e2f 100644 --- a/addons/l10n_nl/models/__init__.py +++ b/addons/l10n_nl/models/__init__.py @@ -3,3 +3,4 @@ from . import account_journal from . import account_chart_template +from . import res_partner diff --git a/addons/l10n_nl/models/res_partner.py b/addons/l10n_nl/models/res_partner.py new file mode 100644 index 00000000000..79c5d8c05db --- /dev/null +++ b/addons/l10n_nl/models/res_partner.py @@ -0,0 +1,9 @@ +# coding: utf-8 +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + l10n_nl_kvk = fields.Char(string='KVK-nummer') + l10n_nl_oin = fields.Char(string='Organisatie Indentificatie Nummer') diff --git a/addons/l10n_nl/views/res_partner_views.xml b/addons/l10n_nl/views/res_partner_views.xml new file mode 100644 index 00000000000..edaa47965e3 --- /dev/null +++ b/addons/l10n_nl/views/res_partner_views.xml @@ -0,0 +1,14 @@ + + + + res.partner.form.inherit.l10n_nl + res.partner + + + + + + + + + diff --git a/addons/l10n_nl_edi/__init__.py b/addons/l10n_nl_edi/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_nl_edi/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_nl_edi/__manifest__.py b/addons/l10n_nl_edi/__manifest__.py new file mode 100644 index 00000000000..6f8112e369d --- /dev/null +++ b/addons/l10n_nl_edi/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Netherlands - E-Invoicing (NLCIUS 1.0.3)', + 'version': '0.1', + 'category': 'Accounting/Localizations/EDI', + 'summary': 'E-Invoicing, Universal Business Language (NLCIUS 1.0.3), nlc', + 'description': """ +NLCIUS is the Dutch implementation of EN 16931 norm. Both for UBL and UN / CEFACT XML CII. + """, + 'depends': ['l10n_nl', 'account_edi_ubl_bis3'], + 'data': [ + 'data/account_edi_data.xml', + 'data/nlcius_template.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/l10n_nl_edi/data/account_edi_data.xml b/addons/l10n_nl_edi/data/account_edi_data.xml new file mode 100644 index 00000000000..95418d9de35 --- /dev/null +++ b/addons/l10n_nl_edi/data/account_edi_data.xml @@ -0,0 +1,11 @@ + + + + + + NLCIUS (Netherlands) + nlcius_1 + + + + diff --git a/addons/l10n_nl_edi/data/nlcius_template.xml b/addons/l10n_nl_edi/data/nlcius_template.xml new file mode 100644 index 00000000000..9ae1a1a82c6 --- /dev/null +++ b/addons/l10n_nl_edi/data/nlcius_template.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/addons/l10n_nl_edi/models/__init__.py b/addons/l10n_nl_edi/models/__init__.py new file mode 100644 index 00000000000..80dd2914f7b --- /dev/null +++ b/addons/l10n_nl_edi/models/__init__.py @@ -0,0 +1,3 @@ +# -*- encoding: utf-8 -*- + +from . import account_edi_format diff --git a/addons/l10n_nl_edi/models/account_edi_format.py b/addons/l10n_nl_edi/models/account_edi_format.py new file mode 100644 index 00000000000..b6210e4132f --- /dev/null +++ b/addons/l10n_nl_edi/models/account_edi_format.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- + +from odoo import models, _ + +import base64 + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + #################################################### + # Import + #################################################### + + def _is_ubl(self, filename, tree): + """ OVERRIDE so that the generic ubl parser does not parse BIS3 any longer. + """ + is_ubl = super()._is_ubl(filename, tree) + return is_ubl and not self._is_nlcius(filename, tree) + + def _is_nlcius(self, filename, tree): + profile_id = tree.find('./{*}ProfileID') + customization_id = tree.find('./{*}CustomizationID') + return tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}Invoice' and \ + profile_id is not None and 'peppol' in profile_id.text and \ + customization_id is not None and 'nlcius' in customization_id.text + + def _bis3_get_extra_partner_domains(self, tree): + if self.code == 'nlcius_1': + endpoint = tree.find('./{*}AccountingSupplierParty/{*}Party/{*}EndpointID') + if endpoint is not None: + scheme = endpoint.attrib['schemeID'] + if scheme == '0106' and endpoint.text: + return [('l10n_nl_kvk', '=', endpoint.text)] + elif scheme == '0190' and endpoint.text: + return [('l10n_nl_oin', '=', endpoint.text)] + return [] + + #################################################### + # Export + #################################################### + + def _get_nlcius_values(self, invoice): + values = super()._get_bis3_values(invoice) + values.update({ + 'customization_id': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0', + 'payment_means_code': 30, + }) + + for partner_vals in (values['customer_vals'], values['supplier_vals']): + partner = partner_vals['partner'] + endpoint = partner.l10n_nl_oin or partner.l10n_nl_kvk + if partner.country_code == 'NL' and endpoint: + scheme = '0190' if partner.l10n_nl_oin else '0106' + partner_vals.update({ + 'bis3_endpoint': endpoint, + 'bis3_endpoint_scheme': scheme, + 'legal_entity': endpoint, + 'legal_entity_scheme': scheme, + 'partner_identification': endpoint, + }) + + return values + + def _export_nlcius(self, invoice): + self.ensure_one() + # Create file content. + xml_content = b"" + xml_content += self.env.ref('l10n_nl_edi.export_nlcius_invoice')._render(self._get_nlcius_values(invoice)) + vat = invoice.company_id.partner_id.commercial_partner_id.vat + xml_name = 'nlcius-%s%s%s.xml' % (vat or '', '-' if vat else '', invoice.name.replace('/', '_')) + return self.env['ir.attachment'].create({ + 'name': xml_name, + 'datas': base64.encodebytes(xml_content), + 'res_model': 'account.move', + 'res_id': invoice.id, + 'mimetype': 'application/xml' + }) + + #################################################### + # Account.edi.format override + #################################################### + + def _check_move_configuration(self, invoice): + res = super()._check_move_configuration(invoice) + if self.code != 'nlcius_1': + return res + + errors = [] + + supplier = invoice.company_id.partner_id.commercial_partner_id + if not supplier.street or not supplier.zip or not supplier.city: + errors.append(_("The supplier's address must include street, zip and city (%s).", supplier.display_name)) + if supplier.country_code == 'NL' and not supplier.l10n_nl_kvk and not supplier.l10n_nl_oin: + errors.append(_("The supplier %s must have a KvK-nummer or OIN.", supplier.display_name)) + if not supplier.vat: + errors.append(_("Please define a VAT number for '%s'.", supplier.display_name)) + + customer = invoice.commercial_partner_id + if customer.country_code == 'NL' and (not customer.street or not customer.zip or not customer.city): + errors.append(_("Customer's address must include street, zip and city (%s).", customer.display_name)) + if customer.country_code == 'NL' and not customer.l10n_nl_kvk and not customer.l10n_nl_oin: + errors.append(_("The customer %s must have a KvK-nummer or OIN.", customer.display_name)) + + if not invoice.partner_bank_id: + errors.append(_("The supplier %s must have a bank account.", supplier.display_name)) + + if invoice.invoice_line_ids.filtered(lambda l: not (l.product_id.name or l.name)): + errors.append(_('Each invoice line must have a product or a label.')) + + if invoice.invoice_line_ids.tax_ids.invoice_repartition_line_ids.filtered(lambda r: r.use_in_tax_closing) and \ + not supplier.vat: + errors.append(_("When vat is present, the supplier must have a vat number.")) + + return errors + + def _is_compatible_with_journal(self, journal): + self.ensure_one() + if self.code != 'nlcius_1': + return super()._is_compatible_with_journal(journal) + return journal.type == 'sale' and journal.country_code == 'NL' + + def _post_invoice_edi(self, invoices, test_mode=False): + self.ensure_one() + if self.code != 'nlcius_1': + return super()._post_invoice_edi(invoices, test_mode=test_mode) + + invoice = invoices # no batch ensure that there is only one invoice + attachment = self._export_nlcius(invoice) + return {invoice: {'attachment': attachment}} + + def _is_embedding_to_invoice_pdf_needed(self): + self.ensure_one() + if self.code != 'nlcius_1': + return super()._is_embedding_to_invoice_pdf_needed() + return False + + def _create_invoice_from_xml_tree(self, filename, tree): + self.ensure_one() + if self.code == 'nlcius_1' and self._is_nlcius(filename, tree): + return self._decode_bis3(tree, self.env['account.move']) + return super()._create_invoice_from_xml_tree(filename, tree) + + def _update_invoice_from_xml_tree(self, filename, tree, invoice): + self.ensure_one() + if self.code == 'nlcius_1' and self._is_nlcius(filename, tree): + return self._decode_bis3(tree, invoice) + return super()._update_invoice_from_xml_tree(filename, tree, invoice) diff --git a/addons/l10n_nl_edi/test_xml_file/nlcius_test.xml b/addons/l10n_nl_edi/test_xml_file/nlcius_test.xml new file mode 100644 index 00000000000..ffc4ab19b0d --- /dev/null +++ b/addons/l10n_nl_edi/test_xml_file/nlcius_test.xml @@ -0,0 +1,138 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV/2020/11/0001 + 2020-11-02 + 2020-11-02 + 380 + + EUR + + Azure Interior + + + + 77777677 + + 77777677 + + + De Silva St 4557 + + Fremont + 94538 + + NL + + + + NL000021504B09 + + VAT + + + + partner_a + 77777677 + + + + + + 12345678 + + 12345678 + + + NL Company + + + Bloemstraat 42 + + Groningen + 6700 + + NL + + + + NL000043603B11 + + VAT + + + + NL Company + 12345678 + + + NL Company + +31 6 12345678 + info@company.nlexample.com + + + + + 30 + + BE68 5390 0754 7034 + + + + + 67.20 + + + 320.00 + 67.20 + + S + 21.0 + + VAT + + + + + + + + 320.00 + 320.00 + 387.20 + 0.00 + 387.20 + + + + + 1 + + + 1.0 + 320.00 + + [E-COM07] Large Cabinet + Large Cabinet + + E-COM07 + + + + S + 21.0 + + VAT + + + + + + + 320.00 + 1.0 + + + + diff --git a/addons/l10n_nl_edi/tests/__init__.py b/addons/l10n_nl_edi/tests/__init__.py new file mode 100644 index 00000000000..7063d8432b3 --- /dev/null +++ b/addons/l10n_nl_edi/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_nlcius diff --git a/addons/l10n_nl_edi/tests/test_nlcius.py b/addons/l10n_nl_edi/tests/test_nlcius.py new file mode 100644 index 00000000000..229b06d9b10 --- /dev/null +++ b/addons/l10n_nl_edi/tests/test_nlcius.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account_edi.tests.common import AccountEdiTestCommon +from freezegun import freeze_time + + +class TestUBL(AccountEdiTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='l10n_nl.l10nnl_chart_template', edi_format_ref='l10n_nl_edi.edi_nlcius_1'): + super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref) + + cls.company_data['company'].partner_id.write({ + 'street': 'Archefstraat 42', + 'zip': '1000', + 'city': 'Amsterdam', + 'country_id': cls.env.ref('base.nl').id, + 'l10n_nl_kvk': '82777822', + 'vat': 'NL000099998B57', + }) + + cls.partner_a.write({ + 'l10n_nl_kvk': '77777677', + 'country_id': cls.env.ref('base.be').id, + 'vat': 'BE0477472701', + }) + + bank_account = cls.env['res.partner.bank'].create({ + 'acc_number': 'BE93999574162167', + 'partner_id': cls.partner_a.id, + }) + + cls.tax_sale_b.write({ + 'amount': 15 + }) + + cls.invoice = cls.env['account.move'].create({ + 'partner_id': cls.partner_a.id, + 'move_type': 'out_invoice', + 'partner_bank_id': bank_account.id, + 'invoice_date_due': '2020-12-16', + 'invoice_line_ids': [ + (0, 0, { + 'product_id': cls.product_a.id, + 'quantity': 150, + 'price_unit': 250, + 'discount': 10, + 'tax_ids': [(6, 0, cls.tax_sale_a.ids)], + }), + (0, 0, { + 'product_id': cls.product_b.id, + 'quantity': 12, + 'price_unit': 100, + 'tax_ids': [(6, 0, cls.tax_sale_b.ids)], + }), + ] + }) + + cls.expected_invoice_values = ''' + + urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV/2020/12/0001 + 2020-12-16 + 2020-12-16 + 380 + EUR + partner_a + + + 82777822 + + 82777822 + + + company_1_data + + + Archefstraat 42 + Amsterdam + 1000 + + NL + + + + NL000099998B57 + + VAT + + + + company_1_data + 82777822 + + + company_1_data + + + + + + BE0477472701 + + partner_a + + + + BE + + + + BE0477472701 + + VAT + + + + partner_a + + + partner_a + + + + + 30 + + BE93 9995 7416 2167 + + + + 7267.50 + + 33750.00 + 7087.50 + + S + 21.0 + + VAT + + + + + 1200.00 + 180.00 + + S + 15.0 + + VAT + + + + + + 34950.00 + 34950.00 + 42217.50 + 0.00 + 42217.50 + + + 1 + Discount (10.0 %) + 150.0 + 33750.00 + + product_a + product_a + + S + 21.0 + + VAT + + + + + 225.00 + 150.0 + + + + 2 + 12.0 + 1200.00 + + product_b + product_b + + S + 15.0 + + VAT + + + + + 100.00 + 12.0 + + + + ''' + + def test_nlcius_import(self): + invoice = self.env['account.move'].with_context(default_move_type='in_invoice').create({}) + invoice_count = len(self.env['account.move'].search([])) + self.update_invoice_from_file('l10n_nl_edi', 'test_xml_file', 'nlcius_test.xml', invoice) + self.assertEqual(len(self.env['account.move'].search([])), invoice_count) + self.assertEqual(invoice.amount_total, 387.2) + self.assertEqual(invoice.amount_tax, 67.2) + self.assertEqual(invoice.partner_id, self.partner_a) + + @freeze_time('2020-12-16') + def test_nlcius_export(self): + self.assert_generated_file_equal(self.invoice, self.expected_invoice_values) diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index d7481b3969f..1672b417e89 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -198,6 +198,7 @@ class Partner(models.Model): city = fields.Char() state_id = fields.Many2one("res.country.state", string='State', ondelete='restrict', domain="[('country_id', '=?', country_id)]") country_id = fields.Many2one('res.country', string='Country', ondelete='restrict') + country_code = fields.Char(related='country_id.code', string="Country Code") partner_latitude = fields.Float(string='Geo Latitude', digits=(10, 7)) partner_longitude = fields.Float(string='Geo Longitude', digits=(10, 7)) email = fields.Char() diff --git a/odoo/addons/base/views/res_partner_views.xml b/odoo/addons/base/views/res_partner_views.xml index 834c899c22c..add35cc2c92 100644 --- a/odoo/addons/base/views/res_partner_views.xml +++ b/odoo/addons/base/views/res_partner_views.xml @@ -156,6 +156,7 @@ +