778 lines
37 KiB
Python
778 lines
37 KiB
Python
|
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("<strong>%s</strong>") % \
|
|||
|
_("Format used to import the invoice: %s",
|
|||
|
self.env['ir.model']._get(self._name).name)
|
|||
|
|
|||
|
if logs:
|
|||
|
body += Markup("<ul>%s</ul>") % \
|
|||
|
Markup().join(Markup("<li>%s</li>") % l for l in logs)
|
|||
|
|
|||
|
invoice.message_post(body=body)
|
|||
|
|
|||
|
# For UBL, we should override the computed tax amount if it is less than 0.05 different of the one in the xml.
|
|||
|
# In order to support use case where the tax total is adapted for rounding purpose.
|
|||
|
# This has to be done after the first import in order to let Odoo compute the taxes before overriding if needed.
|
|||
|
with invoice._get_edi_creation() as invoice:
|
|||
|
self._correct_invoice_tax_amount(tree, invoice)
|
|||
|
|
|||
|
attachments = self._import_attachments(invoice, tree)
|
|||
|
if attachments:
|
|||
|
invoice.with_context(no_new_invoice=True).message_post(attachment_ids=attachments.ids)
|
|||
|
|
|||
|
return True
|
|||
|
|
|||
|
def _import_attachments(self, invoice, tree):
|
|||
|
# Import the embedded PDF in the xml if some are found
|
|||
|
attachments = self.env['ir.attachment']
|
|||
|
additional_docs = tree.findall('./{*}AdditionalDocumentReference')
|
|||
|
for document in additional_docs:
|
|||
|
attachment_name = document.find('{*}ID')
|
|||
|
attachment_data = document.find('{*}Attachment/{*}EmbeddedDocumentBinaryObject')
|
|||
|
if attachment_name is not None \
|
|||
|
and attachment_data is not None \
|
|||
|
and attachment_data.attrib.get('mimeCode') == 'application/pdf':
|
|||
|
text = attachment_data.text
|
|||
|
# Normalize the name of the file : some e-fff emitters put the full path of the file
|
|||
|
# (Windows or Linux style) and/or the name of the xml instead of the pdf.
|
|||
|
# Get only the filename with a pdf extension.
|
|||
|
name = (attachment_name.text or 'invoice').split('\\')[-1].split('/')[-1].split('.')[0] + '.pdf'
|
|||
|
attachment = self.env['ir.attachment'].create({
|
|||
|
'name': name,
|
|||
|
'res_id': invoice.id,
|
|||
|
'res_model': 'account.move',
|
|||
|
'datas': text + '=' * (len(text) % 3), # Fix incorrect padding
|
|||
|
'type': 'binary',
|
|||
|
'mimetype': 'application/pdf',
|
|||
|
})
|
|||
|
# Upon receiving an email (containing an xml) with a configured alias to create invoice, the xml is
|
|||
|
# set as the main_attachment. To be rendered in the form view, the pdf should be the main_attachment.
|
|||
|
if invoice.message_main_attachment_id and \
|
|||
|
invoice.message_main_attachment_id.name.endswith('.xml') and \
|
|||
|
'pdf' not in invoice.message_main_attachment_id.mimetype:
|
|||
|
invoice._message_set_main_attachment_id(attachment, force=True, filter_xml=False)
|
|||
|
attachments |= attachment
|
|||
|
|
|||
|
return attachments
|
|||
|
|
|||
|
def _import_partner(self, company_id, name, phone, email, vat, country_code=False, peppol_eas=False, peppol_endpoint=False):
|
|||
|
""" Retrieve the partner, if no matching partner is found, create it (only if he has a vat and a name) """
|
|||
|
logs = []
|
|||
|
if peppol_eas and peppol_endpoint:
|
|||
|
domain = [('peppol_eas', '=', peppol_eas), ('peppol_endpoint', '=', peppol_endpoint)]
|
|||
|
else:
|
|||
|
domain = False
|
|||
|
partner = self.env['res.partner'] \
|
|||
|
.with_company(company_id) \
|
|||
|
._retrieve_partner(name=name, phone=phone, email=email, vat=vat, domain=domain)
|
|||
|
if not partner and name and vat:
|
|||
|
partner_vals = {'name': name, 'email': email, 'phone': phone}
|
|||
|
if peppol_eas and peppol_endpoint:
|
|||
|
partner_vals.update({'peppol_eas': peppol_eas, 'peppol_endpoint': peppol_endpoint})
|
|||
|
country = self.env.ref(f'base.{country_code.lower()}', raise_if_not_found=False) if country_code else False
|
|||
|
if country:
|
|||
|
partner_vals['country_id'] = country.id
|
|||
|
partner = self.env['res.partner'].create(partner_vals)
|
|||
|
if vat and self.env['res.partner']._run_vat_test(vat, country, partner.is_company):
|
|||
|
partner.vat = vat
|
|||
|
logs.append(_("Could not retrieve a partner corresponding to '%s'. A new partner was created.", name))
|
|||
|
return partner, logs
|
|||
|
|
|||
|
def _import_partner_bank(self, invoice, bank_details):
|
|||
|
""" Retrieve the bank account, if no matching bank account is found, create it """
|
|||
|
# clear the context, because creation of partner when importing should not depend on the context default values
|
|||
|
ResPartnerBank = self.env['res.partner.bank'].with_env(self.env(context=clean_context(self.env.context)))
|
|||
|
bank_details = list(map(sanitize_account_number, bank_details))
|
|||
|
partner = self.env.company.partner_id if invoice.is_inbound() else invoice.partner_id
|
|||
|
banks_to_create = []
|
|||
|
acc_number_partner_bank_dict = {
|
|||
|
bank.sanitized_acc_number: bank
|
|||
|
for bank in ResPartnerBank.search(
|
|||
|
[('company_id', 'in', [False, invoice.company_id.id]), ('acc_number', 'in', bank_details)]
|
|||
|
)
|
|||
|
}
|
|||
|
for account_number in bank_details:
|
|||
|
partner_bank = acc_number_partner_bank_dict.get(account_number, ResPartnerBank)
|
|||
|
if partner_bank.partner_id == partner:
|
|||
|
invoice.partner_bank_id = partner_bank
|
|||
|
return
|
|||
|
elif not partner_bank and account_number:
|
|||
|
banks_to_create.append({
|
|||
|
'acc_number': account_number,
|
|||
|
'partner_id': partner.id,
|
|||
|
})
|
|||
|
if banks_to_create:
|
|||
|
invoice.partner_bank_id = ResPartnerBank.create(banks_to_create)[0]
|
|||
|
|
|||
|
def _import_document_allowance_charges(self, tree, record, tax_type, qty_factor=1):
|
|||
|
logs = []
|
|||
|
xpaths = self._get_document_allowance_charge_xpaths()
|
|||
|
line_vals = []
|
|||
|
for allow_el in tree.iterfind(xpaths['root']):
|
|||
|
name = allow_el.findtext(xpaths['reason']) or ""
|
|||
|
# Charge indicator factor: -1 for discount, 1 for charge
|
|||
|
charge_indicator = -1 if allow_el.findtext(xpaths['charge_indicator']).lower() == 'false' else 1
|
|||
|
amount = float(allow_el.findtext(xpaths['amount']) or 0)
|
|||
|
base_amount = float(allow_el.findtext(xpaths['base_amount']) or 0)
|
|||
|
if base_amount:
|
|||
|
price_unit = base_amount * charge_indicator * qty_factor
|
|||
|
percentage = float(allow_el.findtext(xpaths['percentage']) or 100)
|
|||
|
quantity = percentage / 100
|
|||
|
else:
|
|||
|
price_unit = amount * charge_indicator * qty_factor
|
|||
|
quantity = 1
|
|||
|
|
|||
|
# Taxes
|
|||
|
tax_ids = []
|
|||
|
for tax_percent_node in allow_el.iterfind(xpaths['tax_percentage']):
|
|||
|
tax_amount = float(tax_percent_node.text)
|
|||
|
tax = self.env['account.tax'].search([
|
|||
|
*self.env['account.tax']._check_company_domain(record.company_id),
|
|||
|
('amount', '=', tax_amount),
|
|||
|
('amount_type', '=', 'percent'),
|
|||
|
('type_tax_use', '=', tax_type),
|
|||
|
], limit=1)
|
|||
|
if tax:
|
|||
|
tax_ids += tax.ids
|
|||
|
elif name:
|
|||
|
logs.append(_(
|
|||
|
"Could not retrieve the tax: %(tax_percentage)s %% for line '%(line)s'.",
|
|||
|
tax_percentage=tax_amount,
|
|||
|
line=name,
|
|||
|
))
|
|||
|
else:
|
|||
|
logs.append(
|
|||
|
_("Could not retrieve the tax: %s for the document level allowance/charge.", tax_amount))
|
|||
|
|
|||
|
line_vals.append([name, quantity, price_unit, tax_ids])
|
|||
|
return record._get_line_vals_list(line_vals), logs
|
|||
|
|
|||
|
def _import_currency(self, tree, xpath):
|
|||
|
logs = []
|
|||
|
currency_name = tree.findtext(xpath)
|
|||
|
currency = self.env['res.currency']
|
|||
|
if currency_name is not None:
|
|||
|
currency = currency.with_context(active_test=False).search([
|
|||
|
('name', '=', currency_name),
|
|||
|
], limit=1)
|
|||
|
if currency:
|
|||
|
if not currency.active:
|
|||
|
logs.append(_("The currency '%s' is not active.", currency.name))
|
|||
|
else:
|
|||
|
logs.append(_("Could not retrieve currency: %s. Did you enable the multicurrency option "
|
|||
|
"and activate the currency?", currency_name))
|
|||
|
return currency.id, logs
|
|||
|
|
|||
|
def _import_description(self, tree, xpaths):
|
|||
|
description = ""
|
|||
|
for xpath in xpaths:
|
|||
|
note = tree.findtext(xpath)
|
|||
|
if note:
|
|||
|
description += f"<p>{html_escape(note)}</p>"
|
|||
|
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
|