from markupsafe import Markup from odoo import _, models, Command from odoo.addons.base.models.res_bank import sanitize_account_number from odoo.exceptions import UserError, ValidationError from odoo.tools import float_repr, format_list from odoo.tools.float_utils import float_round from odoo.tools.misc import clean_context, formatLang, html_escape from odoo.tools.xml_utils import find_xml_value # ------------------------------------------------------------------------- # UNIT OF MEASURE # ------------------------------------------------------------------------- UOM_TO_UNECE_CODE = { 'uom.product_uom_unit': 'C62', 'uom.product_uom_dozen': 'DZN', 'uom.product_uom_kgm': 'KGM', 'uom.product_uom_gram': 'GRM', 'uom.product_uom_day': 'DAY', 'uom.product_uom_hour': 'HUR', 'uom.product_uom_ton': 'TNE', 'uom.product_uom_meter': 'MTR', 'uom.product_uom_km': 'KMT', 'uom.product_uom_cm': 'CMT', 'uom.product_uom_litre': 'LTR', 'uom.product_uom_cubic_meter': 'MTQ', 'uom.product_uom_lb': 'LBR', 'uom.product_uom_oz': 'ONZ', 'uom.product_uom_inch': 'INH', 'uom.product_uom_foot': 'FOT', 'uom.product_uom_mile': 'SMI', 'uom.product_uom_floz': 'OZA', 'uom.product_uom_qt': 'QT', 'uom.product_uom_gal': 'GLL', 'uom.product_uom_cubic_inch': 'INQ', 'uom.product_uom_cubic_foot': 'FTQ', } # ------------------------------------------------------------------------- # ELECTRONIC ADDRESS SCHEME (EAS), see https://docs.peppol.eu/poacc/billing/3.0/codelist/eas/ # ------------------------------------------------------------------------- EAS_MAPPING = { 'AD': {'9922': 'vat'}, 'AL': {'9923': 'vat'}, 'AT': {'9915': 'vat'}, 'AU': {'0151': 'vat'}, 'BA': {'9924': 'vat'}, 'BE': {'0208': 'company_registry'}, 'BG': {'9926': 'vat'}, 'CH': {'9927': 'vat'}, 'CY': {'9928': 'vat'}, 'CZ': {'9929': 'vat'}, 'DE': {'9930': 'vat'}, 'DK': {'0184': 'company_registry', '0198': 'vat'}, 'EE': {'9931': 'vat'}, 'ES': {'9920': 'vat'}, 'FI': {'0216': None}, 'FR': {'0009': 'siret', '9957': 'vat'}, 'SG': {'0195': 'l10n_sg_unique_entity_number'}, 'GB': {'9932': 'vat'}, 'GR': {'9933': 'vat'}, 'HR': {'9934': 'vat'}, 'HU': {'9910': 'l10n_hu_eu_vat'}, 'IE': {'9935': 'vat'}, 'IS': {'0196': 'vat'}, 'IT': {'0211': 'vat', '0210': 'l10n_it_codice_fiscale'}, 'JP': {'0221': 'vat'}, 'LI': {'9936': 'vat'}, 'LT': {'9937': 'vat'}, 'LU': {'9938': 'vat'}, 'LV': {'9939': 'vat'}, 'MC': {'9940': 'vat'}, 'ME': {'9941': 'vat'}, 'MK': {'9942': 'vat'}, 'MT': {'9943': 'vat'}, # Do not add the vat for NL, since: "[NL-R-003] For suppliers in the Netherlands, the legal entity identifier # MUST be either a KVK or OIN number (schemeID 0106 or 0190)" in the Bis 3 rules (in PartyLegalEntity/CompanyID). 'NL': {'0106': None, '0190': None}, 'NO': {'0192': 'l10n_no_bronnoysund_number'}, 'NZ': {'0088': 'company_registry'}, 'PL': {'9945': 'vat'}, 'PT': {'9946': 'vat'}, 'RO': {'9947': 'vat'}, 'RS': {'9948': 'vat'}, 'SE': {'0007': 'company_registry'}, 'SI': {'9949': 'vat'}, 'SK': {'9950': 'vat'}, 'SM': {'9951': 'vat'}, 'TR': {'9952': 'vat'}, 'VA': {'9953': 'vat'}, } class AccountEdiCommon(models.AbstractModel): _name = "account.edi.common" _description = "Common functions for EDI documents: generate the data, the constraints, etc" # ------------------------------------------------------------------------- # HELPERS # ------------------------------------------------------------------------- def format_float(self, amount, precision_digits): if amount is None: return None return float_repr(float_round(amount, precision_digits), precision_digits) def _get_currency_decimal_places(self, currency_id): # Allows other documents to easily override in case there is a flat max precision number return currency_id.decimal_places def _get_uom_unece_code(self, uom): """ list of codes: https://docs.peppol.eu/poacc/billing/3.0/codelist/UNECERec20/ or https://unece.org/fileadmin/DAM/cefact/recommendations/bkup_htm/add2c.htm (sorted by letter) """ xmlid = uom.get_external_id() if xmlid and uom.id in xmlid: return UOM_TO_UNECE_CODE.get(xmlid[uom.id], 'C62') return 'C62' def _find_value(self, xpaths, tree, nsmap=False): """ Iteratively queries the tree using the xpaths and returns a result as soon as one is found """ if not isinstance(xpaths, (tuple, list)): xpaths = [xpaths] for xpath in xpaths: # functions from ElementTree like "findtext" do not fully implement xpath, use "xpath" (from lxml) instead # (e.g. "//node[string-length(text()) > 5]" raises an invalidPredicate exception with "findtext") val = find_xml_value(xpath, tree, nsmap) if val: return val # ------------------------------------------------------------------------- # TAXES # ------------------------------------------------------------------------- def _validate_taxes(self, tax_ids): """ Validate the structure of the tax repartition lines (invalid structure could lead to unexpected results) """ for tax in tax_ids: try: tax._validate_repartition_lines() except ValidationError as e: error_msg = _("Tax '%(tax_name)s' is invalid: %(error_message)s", tax_name=tax.name, error_message=e.args[0]) # args[0] gives the error message raise ValidationError(error_msg) def _get_tax_unece_codes(self, customer, supplier, tax): """ Source: doc of Peppol (but the CEF norm is also used by factur-x, yet not detailed) https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-TaxTotal/cac-TaxSubtotal/cac-TaxCategory/cbc-TaxExemptionReasonCode/ https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/ https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5305/ :returns: { tax_category_code: str, tax_exemption_reason_code: str, tax_exemption_reason: str, } """ def create_dict(tax_category_code=None, tax_exemption_reason_code=None, tax_exemption_reason=None): return { 'tax_category_code': tax_category_code, 'tax_exemption_reason_code': tax_exemption_reason_code, 'tax_exemption_reason': tax_exemption_reason, } # add Norway, Iceland, Liechtenstein european_economic_area = self.env.ref('base.europe').country_ids.mapped('code') + ['NO', 'IS', 'LI'] if customer.country_id.code == 'ES' and customer.zip: if customer.zip[:2] in ('35', '38'): # Canary # [BR-IG-10]-A VAT breakdown (BG-23) with VAT Category code (BT-118) "IGIC" shall not have a VAT # exemption reason code (BT-121) or VAT exemption reason text (BT-120). return create_dict(tax_category_code='L') if customer.zip[:2] in ('51', '52'): return create_dict(tax_category_code='M') # Ceuta & Mellila if supplier.country_id == customer.country_id: if not tax or tax.amount == 0: # in theory, you should indicate the precise law article return create_dict(tax_category_code='E', tax_exemption_reason=_('Articles 226 items 11 to 15 Directive 2006/112/EN')) else: return create_dict(tax_category_code='S') # standard VAT if supplier.country_id.code in european_economic_area and supplier.vat: if tax.amount != 0: # otherwise, the validator will complain because G and K code should be used with 0% tax return create_dict(tax_category_code='S') if customer.country_id.code not in european_economic_area: return create_dict( tax_category_code='G', tax_exemption_reason_code='VATEX-EU-G', tax_exemption_reason=_('Export outside the EU'), ) if customer.country_id.code in european_economic_area: return create_dict( tax_category_code='K', tax_exemption_reason_code='VATEX-EU-IC', tax_exemption_reason=_('Intra-Community supply'), ) if tax.amount != 0: return create_dict(tax_category_code='S') else: return create_dict(tax_category_code='E', tax_exemption_reason=_('Articles 226 items 11 to 15 Directive 2006/112/EN')) def _get_tax_category_list(self, customer, supplier, taxes): """ Full list: https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm Subset: https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5305/ :param taxes: account.tax records. :return: A list of values to fill the TaxCategory foreach template. """ res = [] for tax in taxes: tax_unece_codes = self._get_tax_unece_codes(customer, supplier, tax) res.append({ 'id': tax_unece_codes.get('tax_category_code'), 'percent': tax.amount if tax.amount_type == 'percent' else False, 'name': tax_unece_codes.get('tax_exemption_reason'), 'tax_scheme_vals': {'id': 'VAT'}, **tax_unece_codes, }) return res # ------------------------------------------------------------------------- # CONSTRAINTS # ------------------------------------------------------------------------- def _check_required_fields(self, record, field_names, custom_warning_message=""): """Check if at least one of the field_names are set on the record/dict :param record: either a recordSet or a dict :param field_names: The field name or list of field name that has to be checked. If a list is provided, check that at least one of them is set. :return: an Error message or None """ if not record: return custom_warning_message or _("The element %(record)s is required on %(field_list)s.", record=record, field_list=format_list(self.env, field_names)) if not isinstance(field_names, (list, tuple)): field_names = (field_names,) has_values = any((field_name in record and record[field_name]) for field_name in field_names) # field is present if has_values: return # field is not present if custom_warning_message or isinstance(record, dict): return custom_warning_message or _( "The element %(record)s is required on %(field_list)s.", record=record, field_list=format_list(self.env, field_names), ) display_field_names = record.fields_get(field_names) if len(field_names) == 1: display_field = f"'{display_field_names[field_names[0]]['string']}'" return _("The field %(field)s is required on %(record)s.", field=display_field, record=record.display_name) else: display_fields = format_list(self.env, [f"'{display_field_names[x]['string']}'" for x in display_field_names]) return _("At least one of the following fields %(field_list)s is required on %(record)s.", field_list=display_fields, record=record.display_name) # ------------------------------------------------------------------------- # COMMON CONSTRAINTS # ------------------------------------------------------------------------- def _invoice_constraints_common(self, invoice): # check that there is a tax on each line for line in invoice.invoice_line_ids.filtered(lambda x: x.display_type not in ('line_note', 'line_section') and x._check_edi_line_tax_required()): if not line.tax_ids: return {'tax_on_line': _("Each invoice line should have at least one tax.")} return {} # ------------------------------------------------------------------------- # Import invoice # ------------------------------------------------------------------------- def _import_invoice_ubl_cii(self, invoice, file_data, new=False): tree = file_data['xml_tree'] # Not able to decode the move_type from the xml. move_type, qty_factor = self._get_import_document_amount_sign(tree) if not move_type: return # Check for inconsistent move_type. journal = invoice.journal_id if journal.type == 'sale': move_type = 'out_' + move_type elif journal.type == 'purchase': move_type = 'in_' + move_type else: return if not new and invoice.move_type != move_type: # with an email alias to create account_move, first the move is created (using alias_defaults, which # contains move_type = 'out_invoice') then the attachment is decoded, if it represents a credit note, # the move type needs to be changed to 'out_refund' types = {move_type, invoice.move_type} if types == {'out_invoice', 'out_refund'} or types == {'in_invoice', 'in_refund'}: invoice.move_type = move_type else: return # Update the invoice. invoice.move_type = move_type with invoice._get_edi_creation() as invoice: logs = self._import_fill_invoice(invoice, tree, qty_factor) if invoice: body = Markup("%s") % \ _("Format used to import the invoice: %s", self.env['ir.model']._get(self._name).name) if logs: body += Markup("
{html_escape(note)}
" return description def _import_prepaid_amount(self, invoice, tree, xpath, qty_factor): logs = [] prepaid_amount = float(tree.findtext(xpath) or 0) if not invoice.currency_id.is_zero(prepaid_amount): amount = prepaid_amount * qty_factor formatted_amount = formatLang(self.env, amount, currency_obj=invoice.currency_id) logs.append(_("A payment of %s was detected.", formatted_amount)) return logs def _import_invoice_lines(self, invoice, tree, xpath, qty_factor): logs = [] lines_values = [] for line_tree in tree.iterfind(xpath): line_values = self._retrieve_line_vals(line_tree, invoice.move_type, qty_factor) line_values['tax_ids'], tax_logs = self._retrieve_taxes( invoice, line_values, invoice.journal_id.type, ) logs += tax_logs if not line_values['product_uom_id']: line_values.pop('product_uom_id') # if no uom, pop it so it's inferred from the product_id lines_values.append(line_values) lines_values += self._retrieve_line_charges(invoice, line_values, line_values['tax_ids']) return lines_values, logs def _retrieve_line_vals(self, tree, document_type=False, qty_factor=1): """ Read the xml invoice, extract the invoice line values, compute the odoo values to fill an invoice line form: quantity, price_unit, discount, product_uom_id. The way of computing invoice line is quite complicated: https://docs.peppol.eu/poacc/billing/3.0/bis/#_calculation_on_line_level (same as in factur-x documentation) line_net_subtotal = ( gross_unit_price - rebate ) * (delivered_qty / basis_qty) - allow_charge_amount with (UBL | CII): * net_unit_price = 'Price/PriceAmount' | 'NetPriceProductTradePrice' (mandatory) (BT-146) * gross_unit_price = 'Price/AllowanceCharge/BaseAmount' | 'GrossPriceProductTradePrice' (optional) (BT-148) * basis_qty = 'Price/BaseQuantity' | 'BasisQuantity' (optional, either below net_price node or gross_price node) (BT-149) * delivered_qty = 'InvoicedQuantity' (invoice) | 'BilledQuantity' (bill) | 'Quantity' (order) (mandatory) (BT-129) * allow_charge_amount = sum of 'AllowanceCharge' | 'SpecifiedTradeAllowanceCharge' (same level as Price) ON THE LINE level (optional) (BT-136 / BT-141) * line_net_subtotal = 'LineExtensionAmount' | 'LineTotalAmount' (mandatory) (BT-131) * rebate = 'Price/AllowanceCharge' | 'AppliedTradeAllowanceCharge' below gross_price node ! (BT-147) "item price discount" which is different from the usual allow_charge_amount gross_unit_price (BT-148) - rebate (BT-147) = net_unit_price (BT-146) In Odoo, we obtain: (1) = price_unit = gross_price_unit / basis_qty = (net_price_unit + rebate) / basis_qty (2) = quantity = delivered_qty (3) = discount (converted into a percentage) = 100 * (1 - price_subtotal / (delivered_qty * price_unit)) (4) = price_subtotal Alternatively, we could also set: quantity = delivered_qty/basis_qty WARNING, the basis quantity parameter is annoying, for instance, an invoice with a line: item A | price per unit of measure/unit price: 30 | uom = 3 pieces | billed qty = 3 | rebate = 2 | untaxed total = 28 Indeed, 30 $ / 3 pieces = 10 $ / piece => 10 * 3 (billed quantity) - 2 (rebate) = 28 UBL ROUNDING: "the result of Item line net amount = ((Item net price (BT-146)÷Item price base quantity (BT-149))×(Invoiced Quantity (BT-129)) must be rounded to two decimals, and the allowance/charge amounts are also rounded separately." It is not possible to do it in Odoo. """ xpath_dict = self._get_line_xpaths(document_type, qty_factor) # basis_qty (optional) basis_qty = float(self._find_value(xpath_dict['basis_qty'], tree) or 1) # gross_price_unit (optional) gross_price_unit = None gross_price_unit_node = tree.find(xpath_dict['gross_price_unit']) if gross_price_unit_node is not None: gross_price_unit = float(gross_price_unit_node.text) # rebate (optional) # Discount. /!\ as no percent discount can be set on a line, need to infer the percentage # from the amount of the actual amount of the discount (the allowance charge) rebate = 0 rebate_node = tree.find(xpath_dict['rebate']) net_price_unit_node = tree.find(xpath_dict['net_price_unit']) if rebate_node is not None: rebate = float(rebate_node.text) elif net_price_unit_node is not None and gross_price_unit_node is not None: rebate = float(gross_price_unit_node.text) - float(net_price_unit_node.text) # net_price_unit (mandatory) net_price_unit = None if net_price_unit_node is not None: net_price_unit = float(net_price_unit_node.text) # delivered_qty (mandatory) delivered_qty = 1 product_vals = {k: self._find_value(v, tree) for k, v in xpath_dict['product'].items()} product = self._import_product(**product_vals) product_uom = self.env['uom.uom'] quantity_node = tree.find(xpath_dict['delivered_qty']) if quantity_node is not None: delivered_qty = float(quantity_node.text) uom_xml = quantity_node.attrib.get('unitCode') if uom_xml: uom_infered_xmlid = [ odoo_xmlid for odoo_xmlid, uom_unece in UOM_TO_UNECE_CODE.items() if uom_unece == uom_xml ] if uom_infered_xmlid: product_uom = self.env.ref(uom_infered_xmlid[0], raise_if_not_found=False) or self.env['uom.uom'] if product and product_uom and product_uom.category_id != product.product_tmpl_id.uom_id.category_id: # uom incompatibility product_uom = self.env['uom.uom'] # line_net_subtotal (mandatory) price_subtotal = None line_total_amount_node = tree.find(xpath_dict['line_total_amount']) if line_total_amount_node is not None: price_subtotal = float(line_total_amount_node.text) # quantity quantity = delivered_qty * qty_factor # Charges are collected (they are used to create new lines), Allowances are transformed into discounts charges = [] discount_amount = 0 for allowance_charge_node in tree.iterfind(xpath_dict['allowance_charge']): charge_indicator = allowance_charge_node.findtext(xpath_dict['allowance_charge_indicator']) amount = float(allowance_charge_node.findtext(xpath_dict['allowance_charge_amount'], default='0')) reason_code = allowance_charge_node.findtext(xpath_dict['allowance_charge_reason_code'], default='') reason = allowance_charge_node.findtext(xpath_dict['allowance_charge_reason'], default='') if charge_indicator.lower() == 'true': charges.append({ 'amount': amount, 'line_quantity': quantity, 'reason': reason, 'reason_code': reason_code, }) else: discount_amount += amount # price_unit charge_amount = sum(d['amount'] for d in charges) allow_charge_amount = discount_amount - charge_amount if gross_price_unit is not None: price_unit = gross_price_unit / basis_qty elif net_price_unit is not None: price_unit = (net_price_unit + rebate) / basis_qty elif price_subtotal is not None: price_unit = (price_subtotal + allow_charge_amount) / (delivered_qty or 1) else: raise UserError(_("No gross price, net price nor line subtotal amount found for line in xml")) # discount discount = 0 if delivered_qty * price_unit != 0 and price_subtotal is not None: discount = 100 * (1 - (price_subtotal - charge_amount) / (delivered_qty * price_unit)) # Sometimes, the xml received is very bad; e.g.: # * unit price = 0, qty = 0, but price_subtotal = -200 # * unit price = 0, qty = 1, but price_subtotal = -200 # * unit price = 1, qty = 0, but price_subtotal = -200 # for instance, when filling a down payment as an document line. The equation in the docstring is not # respected, and the result will not be correct, so we just follow the simple rule below: if net_price_unit is not None and price_subtotal != net_price_unit * (delivered_qty / basis_qty) - allow_charge_amount: if net_price_unit == 0 and delivered_qty == 0: quantity = 1 price_unit = price_subtotal elif net_price_unit == 0: price_unit = price_subtotal / delivered_qty elif delivered_qty == 0: quantity = price_subtotal / price_unit # Start and End date (enterprise fields) deferred_values = {} start_date = end_date = None if self.env['account.move.line']._fields.get('deferred_start_date'): start_date_node = tree.find('./{*}InvoicePeriod/{*}StartDate') end_date_node = tree.find('./{*}InvoicePeriod/{*}EndDate') if start_date_node is not None and end_date_node is not None: # there is a constraint forcing none or the two to be set start_date = start_date_node.text end_date = end_date_node.text deferred_values = { 'deferred_start_date': start_date, 'deferred_end_date': end_date, } return { # vals to be written on the document line 'name': self._find_value(xpath_dict['name'], tree), 'product_id': product.id, 'product_uom_id': product_uom.id, 'price_unit': price_unit, 'quantity': quantity, 'discount': discount, 'tax_nodes': self._get_tax_nodes(tree), # see `_retrieve_taxes` 'charges': charges, # see `_retrieve_line_charges` **deferred_values, } def _import_product(self, **product_vals): return self.env['product.product']._retrieve_product(**product_vals) def _retrieve_fixed_tax(self, company_id, fixed_tax_vals): """ Retrieve the fixed tax at import, iteratively search for a tax: 1. not price_include matching the name and the amount 2. not price_include matching the amount 3. price_include matching the name and the amount 4. price_include matching the amount """ base_domain = [ *self.env['account.journal']._check_company_domain(company_id), ('amount_type', '=', 'fixed'), ('amount', '=', fixed_tax_vals['amount']), ] for price_include in (False, True): for name in (fixed_tax_vals['reason'], False): domain = base_domain + [('price_include', '=', price_include)] if name: domain.append(('name', '=', name)) tax = self.env['account.tax'].search(domain, limit=1) if tax: return tax return self.env['account.tax'] def _retrieve_taxes(self, record, line_values, tax_type): """ Retrieve the taxes on the document line at import. In a UBL/CII xml, the Odoo "price_include" concept does not exist. Hence, first look for a price_include=False, if it is unsuccessful, look for a price_include=True. """ # Taxes: all amounts are tax excluded, so first try to fetch price_include=False taxes, # if no results, try to fetch the price_include=True taxes. If results, need to adapt the price_unit. logs = [] taxes = [] for tax_node in line_values.pop('tax_nodes'): amount = float(tax_node.text) domain = [ *self.env['account.journal']._check_company_domain(record.company_id), ('amount_type', '=', 'percent'), ('type_tax_use', '=', tax_type), ('amount', '=', amount), ] tax = self.env['account.tax'] if hasattr(record, '_get_specific_tax'): tax = record._get_specific_tax(line_values['name'], 'percent', amount, tax_type) if not tax: tax = self.env['account.tax'].search(domain + [('price_include', '=', False)], limit=1) if not tax: tax = self.env['account.tax'].search(domain + [('price_include', '=', True)], limit=1) if not tax: logs.append( _("Could not retrieve the tax: %(amount)s %% for line '%(line)s'.", amount=amount, line=line_values['name']), ) else: taxes.append(tax.id) if tax.price_include: line_values['price_unit'] *= (1 + tax.amount / 100) return taxes, logs def _retrieve_line_charges(self, record, line_values, taxes): """ Handle the charges on the document line at import. For each charge on the line, it creates a new aml. Special case: if the ReasonCode == 'AEO', there is a high chance the xml was produced by Odoo and the corresponding line had a fixed tax, so it first tries to find a matching fixed tax to apply to the current aml. """ charges_vals = [] for charge in line_values.pop('charges'): if charge['reason_code'] == 'AEO': # a 1 eur fixed tax on a line with quantity=2 will yield an AllowanceCharge with amount = 2 charge_copy = charge.copy() charge_copy['amount'] /= charge_copy['line_quantity'] if tax := self._retrieve_fixed_tax(record.company_id, charge_copy): taxes.append(tax.id) if tax.price_include: line_values['price_unit'] += tax.amount continue charges_vals.append([ charge['reason_code'] + " " + charge['reason'], 1, charge['amount'], taxes, ]) return record._get_line_vals_list(charges_vals) def _get_document_allowance_charge_xpaths(self): # OVERRIDE pass def _get_invoice_line_xpaths(self, invoice_line, qty_factor): # OVERRIDE pass def _correct_invoice_tax_amount(self, tree, invoice): pass # To be implemented by the format if needed