792 lines
37 KiB
Python
792 lines
37 KiB
Python
# -*- coding: utf-8 -*-
|
|
from collections import defaultdict
|
|
from lxml import etree
|
|
|
|
from odoo import models, _
|
|
from odoo.tools import html2plaintext, cleanup_xml_node
|
|
from odoo.tools.float_utils import float_round
|
|
|
|
|
|
class AccountEdiXmlUBL20(models.AbstractModel):
|
|
_name = "account.edi.xml.ubl_20"
|
|
_inherit = 'account.edi.common'
|
|
_description = "UBL 2.0"
|
|
|
|
# -------------------------------------------------------------------------
|
|
# EXPORT
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _export_invoice_filename(self, invoice):
|
|
return f"{invoice.name.replace('/', '_')}_ubl_20.xml"
|
|
|
|
def _export_invoice_ecosio_schematrons(self):
|
|
return {
|
|
'invoice': 'org.oasis-open:invoice:2.0',
|
|
'credit_note': 'org.oasis-open:creditnote:2.0',
|
|
}
|
|
|
|
def _get_country_vals(self, country):
|
|
return {
|
|
'country': country,
|
|
|
|
'identification_code': country.code,
|
|
'name': country.name,
|
|
}
|
|
|
|
def _get_partner_party_identification_vals_list(self, partner):
|
|
return []
|
|
|
|
def _get_partner_address_vals(self, partner):
|
|
return {
|
|
'street_name': partner.street,
|
|
'additional_street_name': partner.street2,
|
|
'city_name': partner.city,
|
|
'postal_zone': partner.zip,
|
|
'country_subentity': partner.state_id.name,
|
|
'country_subentity_code': partner.state_id.code,
|
|
'country_vals': self._get_country_vals(partner.country_id),
|
|
}
|
|
|
|
def _get_partner_party_tax_scheme_vals_list(self, partner, role):
|
|
return [{
|
|
'registration_name': partner.name,
|
|
'company_id': partner.vat,
|
|
'registration_address_vals': self._get_partner_address_vals(partner),
|
|
'tax_scheme_vals': {'id': 'VAT'},
|
|
}]
|
|
|
|
def _get_partner_party_legal_entity_vals_list(self, partner):
|
|
commercial_partner = partner.commercial_partner_id
|
|
|
|
return [{
|
|
'commercial_partner': commercial_partner,
|
|
|
|
'registration_name': commercial_partner.name,
|
|
'company_id': commercial_partner.vat,
|
|
'registration_address_vals': self._get_partner_address_vals(commercial_partner),
|
|
}]
|
|
|
|
def _get_partner_contact_vals(self, partner):
|
|
phone = partner.phone or partner.mobile
|
|
return {
|
|
'id': partner.id,
|
|
'name': partner.name,
|
|
'telephone': phone and phone.strip(),
|
|
'electronic_mail': partner.email,
|
|
}
|
|
|
|
def _get_partner_party_vals(self, partner, role):
|
|
return {
|
|
'partner': partner,
|
|
'party_identification_vals': self._get_partner_party_identification_vals_list(partner),
|
|
'party_name_vals': [{'name': partner.name}],
|
|
'postal_address_vals': self._get_partner_address_vals(partner),
|
|
'party_tax_scheme_vals': self._get_partner_party_tax_scheme_vals_list(partner, role),
|
|
'party_legal_entity_vals': self._get_partner_party_legal_entity_vals_list(partner),
|
|
'contact_vals': self._get_partner_contact_vals(partner),
|
|
}
|
|
|
|
def _get_invoice_period_vals_list(self, invoice):
|
|
"""
|
|
For now, we cannot fill this data from an invoice
|
|
This corresponds to the 'delivery or invoice period'. For UBL Bis 3, in the case of intra-community supply,
|
|
the Actual delivery date (BT-72) or the Invoicing period (BG-14) should be present under the form:
|
|
{
|
|
'start_date': str,
|
|
'end_date': str,
|
|
}.
|
|
"""
|
|
return []
|
|
|
|
def _get_delivery_vals_list(self, invoice):
|
|
# the data is optional, except for ubl bis3 (see the override, where we need to set a default delivery address)
|
|
return [{
|
|
'actual_delivery_date': None,
|
|
'delivery_location_vals': {
|
|
'delivery_address_vals': self._get_partner_address_vals(invoice.partner_shipping_id),
|
|
},
|
|
}]
|
|
|
|
def _get_bank_address_vals(self, bank):
|
|
return {
|
|
'street_name': bank.street,
|
|
'additional_street_name': bank.street2,
|
|
'city_name': bank.city,
|
|
'postal_zone': bank.zip,
|
|
'country_subentity': bank.state.name,
|
|
'country_subentity_code': bank.state.code,
|
|
'country_vals': self._get_country_vals(bank.country),
|
|
}
|
|
|
|
def _get_financial_institution_vals(self, bank):
|
|
return {
|
|
'bank': bank,
|
|
'id': bank.bic,
|
|
'id_attrs': {'schemeID': 'BIC'},
|
|
'name': bank.name,
|
|
'address_vals': self._get_bank_address_vals(bank),
|
|
}
|
|
|
|
def _get_financial_institution_branch_vals(self, bank):
|
|
return {
|
|
'bank': bank,
|
|
'id': bank.bic,
|
|
'id_attrs': {'schemeID': 'BIC'},
|
|
'financial_institution_vals': self._get_financial_institution_vals(bank),
|
|
}
|
|
|
|
def _get_financial_account_vals(self, partner_bank):
|
|
vals = {
|
|
'bank_account': partner_bank,
|
|
'id': partner_bank.acc_number.replace(' ', ''),
|
|
}
|
|
|
|
if partner_bank.bank_id:
|
|
vals['financial_institution_branch_vals'] = self._get_financial_institution_branch_vals(partner_bank.bank_id)
|
|
|
|
return vals
|
|
|
|
def _get_invoice_payment_means_vals_list(self, invoice):
|
|
vals = {
|
|
'payment_means_code': 30,
|
|
'payment_means_code_attrs': {'name': 'credit transfer'},
|
|
'payment_due_date': invoice.invoice_date_due or invoice.invoice_date,
|
|
'instruction_id': invoice.payment_reference,
|
|
'payment_id_vals': [invoice.payment_reference or invoice.name],
|
|
}
|
|
|
|
if invoice.partner_bank_id:
|
|
vals['payee_financial_account_vals'] = self._get_financial_account_vals(invoice.partner_bank_id)
|
|
|
|
return [vals]
|
|
|
|
def _get_invoice_payment_terms_vals_list(self, invoice):
|
|
payment_term = invoice.invoice_payment_term_id
|
|
if payment_term:
|
|
# The payment term's note is automatically embedded in a <p> tag in Odoo
|
|
return [{'note_vals': [{'note': html2plaintext(payment_term.note)}]}]
|
|
else:
|
|
return []
|
|
|
|
def _get_invoice_tax_totals_vals_list(self, invoice, taxes_vals):
|
|
tax_totals_vals = {
|
|
'currency': invoice.currency_id,
|
|
'currency_dp': self._get_currency_decimal_places(invoice.currency_id),
|
|
'tax_amount': taxes_vals['tax_amount_currency'],
|
|
'tax_subtotal_vals': [],
|
|
}
|
|
epd_tax_to_discount = self._get_early_payment_discount_grouped_by_tax_rate(invoice)
|
|
for grouping_key, vals in taxes_vals['tax_details'].items():
|
|
if grouping_key['tax_amount_type'] != 'fixed':
|
|
subtotal = {
|
|
'currency': invoice.currency_id,
|
|
'currency_dp': self._get_currency_decimal_places(invoice.currency_id),
|
|
'taxable_amount': vals['base_amount_currency'],
|
|
'tax_amount': vals['tax_amount_currency'],
|
|
'percent': vals['_tax_category_vals_']['percent'],
|
|
'tax_category_vals': vals['_tax_category_vals_'],
|
|
}
|
|
if epd_tax_to_discount:
|
|
# early payment discounts: need to recompute the tax/taxable amounts
|
|
taxable_amount_after_epd = subtotal['taxable_amount'] - epd_tax_to_discount.get(subtotal['percent'], 0)
|
|
tax_amount_after_epd = taxable_amount_after_epd * subtotal['tax_category_vals']['percent'] / 100
|
|
subtotal.update({
|
|
'taxable_amount': taxable_amount_after_epd,
|
|
'tax_amount': tax_amount_after_epd,
|
|
})
|
|
tax_totals_vals['tax_subtotal_vals'].append(subtotal)
|
|
|
|
if epd_tax_to_discount:
|
|
# early payment discounts: hence, need to recompute the total tax amount
|
|
tax_totals_vals['tax_amount'] = sum([subtot['tax_amount'] for subtot in tax_totals_vals['tax_subtotal_vals']])
|
|
return [tax_totals_vals]
|
|
|
|
def _get_invoice_line_item_vals(self, line, taxes_vals):
|
|
""" Method used to fill the cac:InvoiceLine/cac:Item node.
|
|
It provides information about what the product you are selling.
|
|
|
|
:param line: An invoice line.
|
|
:param taxes_vals: The tax details for the current invoice line.
|
|
:return: A python dictionary.
|
|
"""
|
|
product = line.product_id
|
|
taxes = line.tax_ids.flatten_taxes_hierarchy().filtered(lambda t: t.amount_type != 'fixed')
|
|
tax_category_vals_list = self._get_tax_category_list(line.move_id, taxes)
|
|
description = line.name and line.name.replace('\n', ', ')
|
|
return {
|
|
'description': description,
|
|
'name': product.name or description,
|
|
'sellers_item_identification_vals': {'id': product.code},
|
|
'classified_tax_category_vals': tax_category_vals_list,
|
|
}
|
|
|
|
def _get_document_allowance_charge_vals_list(self, invoice):
|
|
"""
|
|
https://docs.peppol.eu/poacc/billing/3.0/bis/#_document_level_allowance_or_charge
|
|
Usage for early payment discounts:
|
|
* Add one document level Allowance per tax rate (VAT included)
|
|
* Add one document level Charge (VAT excluded) with amount = the total sum of the early payment discount
|
|
The difference between these is the cash discount in case of early payment.
|
|
"""
|
|
vals_list = []
|
|
# Early Payment Discount
|
|
epd_tax_to_discount = self._get_early_payment_discount_grouped_by_tax_rate(invoice)
|
|
if epd_tax_to_discount:
|
|
# One Allowance per tax rate (VAT included)
|
|
for tax_amount, discount_amount in epd_tax_to_discount.items():
|
|
vals_list.append({
|
|
'charge_indicator': 'false',
|
|
'allowance_charge_reason_code': '66',
|
|
'allowance_charge_reason': _("Conditional cash/payment discount"),
|
|
'amount': discount_amount,
|
|
'currency_dp': 2,
|
|
'currency_name': invoice.currency_id.name,
|
|
'tax_category_vals': [{
|
|
'id': 'S',
|
|
'percent': tax_amount,
|
|
'tax_scheme_vals': {'id': 'VAT'},
|
|
}],
|
|
})
|
|
# One global Charge (VAT exempted)
|
|
vals_list.append({
|
|
'charge_indicator': 'true',
|
|
'allowance_charge_reason_code': 'ZZZ',
|
|
'allowance_charge_reason': _("Conditional cash/payment discount"),
|
|
'amount': sum(epd_tax_to_discount.values()),
|
|
'currency_dp': 2,
|
|
'currency_name': invoice.currency_id.name,
|
|
'tax_category_vals': [{
|
|
'id': 'E',
|
|
'percent': 0.0,
|
|
'tax_scheme_vals': {'id': 'VAT'},
|
|
}],
|
|
})
|
|
return vals_list
|
|
|
|
def _get_invoice_line_allowance_vals_list(self, line, tax_values_list=None):
|
|
""" Method used to fill the cac:{Invoice,CreditNote,DebitNote}Line>cac:AllowanceCharge node.
|
|
|
|
Allowances are distinguished from charges using the ChargeIndicator node with 'false' as value.
|
|
|
|
Note that allowance charges do not exist for credit notes in UBL 2.0, so if we apply discount in Odoo
|
|
the net price will not be consistent with the unit price, but we cannot do anything about it
|
|
|
|
:param line: An invoice line.
|
|
:return: A list of python dictionaries.
|
|
"""
|
|
fixed_tax_charge_vals_list = []
|
|
for grouping_key, tax_details in tax_values_list['tax_details'].items():
|
|
if grouping_key['tax_amount_type'] == 'fixed':
|
|
fixed_tax_charge_vals_list.append({
|
|
'currency_name': line.currency_id.name,
|
|
'currency_dp': self._get_currency_decimal_places(line.currency_id),
|
|
'charge_indicator': 'true',
|
|
'allowance_charge_reason_code': 'AEO',
|
|
'allowance_charge_reason': tax_details['tax_name'],
|
|
'amount': tax_details['tax_amount_currency'],
|
|
})
|
|
|
|
if not line.discount:
|
|
return fixed_tax_charge_vals_list
|
|
|
|
# Price subtotal with discount subtracted:
|
|
net_price_subtotal = line.price_subtotal
|
|
# Price subtotal without discount subtracted:
|
|
if line.discount == 100.0:
|
|
gross_price_subtotal = 0.0
|
|
else:
|
|
gross_price_subtotal = line.currency_id.round(net_price_subtotal / (1.0 - (line.discount or 0.0) / 100.0))
|
|
|
|
allowance_vals = {
|
|
'currency_name': line.currency_id.name,
|
|
'currency_dp': self._get_currency_decimal_places(line.currency_id),
|
|
|
|
# Must be 'false' since this method is for allowances.
|
|
'charge_indicator': 'false',
|
|
|
|
# A reason should be provided. In Odoo, we only manage discounts.
|
|
# Full code list is available here:
|
|
# https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5189/
|
|
'allowance_charge_reason_code': 95,
|
|
|
|
# The discount should be provided as an amount.
|
|
'amount': gross_price_subtotal - net_price_subtotal,
|
|
}
|
|
|
|
return [allowance_vals] + fixed_tax_charge_vals_list
|
|
|
|
def _get_invoice_line_price_vals(self, line):
|
|
""" Method used to fill the cac:InvoiceLine/cac:Price node.
|
|
It provides information about the price applied for the goods and services invoiced.
|
|
|
|
:param line: An invoice line.
|
|
:return: A python dictionary.
|
|
"""
|
|
# Price subtotal without discount:
|
|
net_price_subtotal = line.price_subtotal
|
|
# Price subtotal with discount:
|
|
if line.discount == 100.0:
|
|
gross_price_subtotal = 0.0
|
|
else:
|
|
gross_price_subtotal = net_price_subtotal / (1.0 - (line.discount or 0.0) / 100.0)
|
|
# Price subtotal with discount / quantity:
|
|
gross_price_unit = gross_price_subtotal / line.quantity if line.quantity else 0.0
|
|
|
|
uom = super()._get_uom_unece_code(line)
|
|
|
|
return {
|
|
'currency': line.currency_id,
|
|
'currency_dp': self._get_currency_decimal_places(line.currency_id),
|
|
|
|
# The price of an item, exclusive of VAT, after subtracting item price discount.
|
|
'price_amount': float_round(gross_price_unit, 10),
|
|
'product_price_dp': self.env['decimal.precision'].precision_get('Product Price'),
|
|
|
|
# The number of item units to which the price applies.
|
|
# setting to None -> the xml will not comprise the BaseQuantity (it's not mandatory)
|
|
'base_quantity': None,
|
|
'base_quantity_attrs': {'unitCode': uom},
|
|
}
|
|
|
|
def _get_invoice_line_tax_totals_vals_list(self, line, taxes_vals):
|
|
""" Method used to fill the cac:TaxTotal node on a line level.
|
|
Uses the same method as the invoice TaxTotal, but can be overridden in other formats.
|
|
"""
|
|
return self._get_invoice_tax_totals_vals_list(line.move_id, taxes_vals)
|
|
|
|
def _get_invoice_line_vals(self, line, line_id, taxes_vals):
|
|
""" Method used to fill the cac:{Invoice,CreditNote,DebitNote}Line node.
|
|
It provides information about the document line.
|
|
|
|
:param line: A document line.
|
|
:return: A python dictionary.
|
|
"""
|
|
allowance_charge_vals_list = self._get_invoice_line_allowance_vals_list(line, tax_values_list=taxes_vals)
|
|
|
|
uom = super()._get_uom_unece_code(line)
|
|
total_fixed_tax_amount = sum(
|
|
vals['amount']
|
|
for vals in allowance_charge_vals_list
|
|
if vals['allowance_charge_reason_code'] == 'AEO'
|
|
)
|
|
return {
|
|
'currency': line.currency_id,
|
|
'currency_dp': self._get_currency_decimal_places(line.currency_id),
|
|
'id': line_id + 1,
|
|
'line_quantity': line.quantity,
|
|
'line_quantity_attrs': {'unitCode': uom},
|
|
'line_extension_amount': line.price_subtotal + total_fixed_tax_amount,
|
|
'allowance_charge_vals': allowance_charge_vals_list,
|
|
'tax_total_vals': self._get_invoice_line_tax_totals_vals_list(line, taxes_vals),
|
|
'item_vals': self._get_invoice_line_item_vals(line, taxes_vals),
|
|
'price_vals': self._get_invoice_line_price_vals(line),
|
|
}
|
|
|
|
def _get_invoice_monetary_total_vals(self, invoice, taxes_vals, line_extension_amount, allowance_total_amount, charge_total_amount):
|
|
""" Method used to fill the cac:{Legal,Requested}MonetaryTotal node"""
|
|
return {
|
|
'currency': invoice.currency_id,
|
|
'currency_dp': self._get_currency_decimal_places(invoice.currency_id),
|
|
'line_extension_amount': line_extension_amount,
|
|
'tax_exclusive_amount': taxes_vals['base_amount_currency'],
|
|
'tax_inclusive_amount': invoice.amount_total,
|
|
'allowance_total_amount': allowance_total_amount or None,
|
|
'charge_total_amount': charge_total_amount or None,
|
|
'prepaid_amount': invoice.amount_total - invoice.amount_residual,
|
|
'payable_amount': invoice.amount_residual,
|
|
}
|
|
|
|
def _apply_invoice_tax_filter(self, base_line, tax_values):
|
|
"""
|
|
To be overridden to apply a specific tax filter
|
|
"""
|
|
return True
|
|
|
|
def _apply_invoice_line_filter(self, invoice_line):
|
|
"""
|
|
To be overridden to apply a specific invoice line filter
|
|
"""
|
|
return True
|
|
|
|
def _get_early_payment_discount_grouped_by_tax_rate(self, invoice):
|
|
"""
|
|
Get the early payment discounts grouped by the tax rate of the product it is linked to
|
|
:returns {float: float}: mapping tax amounts to early payment discount amounts
|
|
"""
|
|
if invoice.invoice_payment_term_id.early_pay_discount_computation != 'mixed':
|
|
return {}
|
|
tax_to_discount = defaultdict(lambda: 0)
|
|
for line in invoice.line_ids.filtered(lambda l: l.display_type == 'epd'):
|
|
for tax in line.tax_ids:
|
|
tax_to_discount[tax.amount] += line.amount_currency
|
|
return tax_to_discount
|
|
|
|
def _export_invoice_vals(self, invoice):
|
|
def grouping_key_generator(base_line, tax_values):
|
|
tax = tax_values['tax_repartition_line'].tax_id
|
|
tax_category_vals = self._get_tax_category_list(invoice, tax)[0]
|
|
grouping_key = {
|
|
'tax_category_id': tax_category_vals['id'],
|
|
'tax_category_percent': tax_category_vals['percent'],
|
|
'_tax_category_vals_': tax_category_vals,
|
|
'tax_amount_type': tax.amount_type,
|
|
}
|
|
# If the tax is fixed, we want to have one group per tax
|
|
# s.t. when the invoice is imported, we can try to guess the fixed taxes
|
|
if tax.amount_type == 'fixed':
|
|
grouping_key['tax_name'] = tax.name
|
|
return grouping_key
|
|
|
|
# Validate the structure of the taxes
|
|
self._validate_taxes(invoice)
|
|
|
|
# Compute the tax details for the whole invoice and each invoice line separately.
|
|
taxes_vals = invoice._prepare_invoice_aggregated_taxes(
|
|
grouping_key_generator=grouping_key_generator,
|
|
filter_tax_values_to_apply=self._apply_invoice_tax_filter,
|
|
filter_invl_to_apply=self._apply_invoice_line_filter,
|
|
)
|
|
|
|
# Fixed Taxes: filter them on the document level, and adapt the totals
|
|
# Fixed taxes are not supposed to be taxes in real live. However, this is the way in Odoo to manage recupel
|
|
# taxes in Belgium. Since only one tax is allowed, the fixed tax is removed from totals of lines but added
|
|
# as an extra charge/allowance.
|
|
fixed_taxes_keys = [k for k in taxes_vals['tax_details'] if k['tax_amount_type'] == 'fixed']
|
|
for key in fixed_taxes_keys:
|
|
fixed_tax_details = taxes_vals['tax_details'].pop(key)
|
|
taxes_vals['tax_amount_currency'] -= fixed_tax_details['tax_amount_currency']
|
|
taxes_vals['tax_amount'] -= fixed_tax_details['tax_amount']
|
|
taxes_vals['base_amount_currency'] += fixed_tax_details['tax_amount_currency']
|
|
taxes_vals['base_amount'] += fixed_tax_details['tax_amount']
|
|
|
|
# Compute values for invoice lines.
|
|
line_extension_amount = 0.0
|
|
|
|
invoice_lines = invoice.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_note', 'line_section'))
|
|
document_allowance_charge_vals_list = self._get_document_allowance_charge_vals_list(invoice)
|
|
invoice_line_vals_list = []
|
|
for line_id, line in enumerate(invoice_lines):
|
|
line_taxes_vals = taxes_vals['tax_details_per_record'][line]
|
|
line_vals = self._get_invoice_line_vals(line, line_id, line_taxes_vals)
|
|
invoice_line_vals_list.append(line_vals)
|
|
|
|
line_extension_amount += line_vals['line_extension_amount']
|
|
|
|
# Compute the total allowance/charge amounts.
|
|
allowance_total_amount = 0.0
|
|
charge_total_amount = 0.0
|
|
for allowance_charge_vals in document_allowance_charge_vals_list:
|
|
if allowance_charge_vals['charge_indicator'] == 'false':
|
|
allowance_total_amount += allowance_charge_vals['amount']
|
|
else:
|
|
charge_total_amount += allowance_charge_vals['amount']
|
|
|
|
supplier = invoice.company_id.partner_id.commercial_partner_id
|
|
customer = invoice.commercial_partner_id
|
|
|
|
# OrderReference/SalesOrderID (sales_order_id) is optional
|
|
sales_order_id = 'sale_line_ids' in invoice.invoice_line_ids._fields \
|
|
and ",".join(invoice.invoice_line_ids.sale_line_ids.order_id.mapped('name'))
|
|
# OrderReference/ID (order_reference) is mandatory inside the OrderReference node !
|
|
order_reference = invoice.ref or invoice.name
|
|
|
|
vals = {
|
|
'builder': self,
|
|
'invoice': invoice,
|
|
'supplier': supplier,
|
|
'customer': customer,
|
|
|
|
'taxes_vals': taxes_vals,
|
|
|
|
'format_float': self.format_float,
|
|
'AddressType_template': 'account_edi_ubl_cii.ubl_20_AddressType',
|
|
'ContactType_template': 'account_edi_ubl_cii.ubl_20_ContactType',
|
|
'PartyType_template': 'account_edi_ubl_cii.ubl_20_PartyType',
|
|
'PaymentMeansType_template': 'account_edi_ubl_cii.ubl_20_PaymentMeansType',
|
|
'PaymentTermsType_template': 'account_edi_ubl_cii.ubl_20_PaymentTermsType',
|
|
'TaxCategoryType_template': 'account_edi_ubl_cii.ubl_20_TaxCategoryType',
|
|
'TaxTotalType_template': 'account_edi_ubl_cii.ubl_20_TaxTotalType',
|
|
'AllowanceChargeType_template': 'account_edi_ubl_cii.ubl_20_AllowanceChargeType',
|
|
'SignatureType_template': 'account_edi_ubl_cii.ubl_20_SignatureType',
|
|
'ResponseType_template': 'account_edi_ubl_cii.ubl_20_ResponseType',
|
|
'DeliveryType_template': 'account_edi_ubl_cii.ubl_20_DeliveryType',
|
|
'MonetaryTotalType_template': 'account_edi_ubl_cii.ubl_20_MonetaryTotalType',
|
|
'InvoiceLineType_template': 'account_edi_ubl_cii.ubl_20_InvoiceLineType',
|
|
'CreditNoteLineType_template': 'account_edi_ubl_cii.ubl_20_CreditNoteLineType',
|
|
'DebitNoteLineType_template': 'account_edi_ubl_cii.ubl_20_DebitNoteLineType',
|
|
'InvoiceType_template': 'account_edi_ubl_cii.ubl_20_InvoiceType',
|
|
'CreditNoteType_template': 'account_edi_ubl_cii.ubl_20_CreditNoteType',
|
|
'DebitNoteType_template': 'account_edi_ubl_cii.ubl_20_DebitNoteType',
|
|
|
|
'vals': {
|
|
'ubl_version_id': 2.0,
|
|
'id': invoice.name,
|
|
'issue_date': invoice.invoice_date,
|
|
'due_date': invoice.invoice_date_due,
|
|
'note_vals': [{'note': html2plaintext(invoice.narration)}] if invoice.narration else [],
|
|
'order_reference': order_reference,
|
|
'sales_order_id': sales_order_id,
|
|
'accounting_supplier_party_vals': {
|
|
'party_vals': self._get_partner_party_vals(supplier, role='supplier'),
|
|
},
|
|
'accounting_customer_party_vals': {
|
|
'party_vals': self._get_partner_party_vals(customer, role='customer'),
|
|
},
|
|
'invoice_period_vals_list': self._get_invoice_period_vals_list(invoice),
|
|
'delivery_vals_list': self._get_delivery_vals_list(invoice),
|
|
'payment_means_vals_list': self._get_invoice_payment_means_vals_list(invoice),
|
|
'payment_terms_vals': self._get_invoice_payment_terms_vals_list(invoice),
|
|
# allowances at the document level, the allowances on invoices (eg. discount) are on line_vals
|
|
'allowance_charge_vals': document_allowance_charge_vals_list,
|
|
'tax_total_vals': self._get_invoice_tax_totals_vals_list(invoice, taxes_vals),
|
|
'monetary_total_vals': self._get_invoice_monetary_total_vals(
|
|
invoice,
|
|
taxes_vals,
|
|
line_extension_amount,
|
|
allowance_total_amount,
|
|
charge_total_amount,
|
|
),
|
|
'line_vals': invoice_line_vals_list,
|
|
'currency_dp': self._get_currency_decimal_places(invoice.currency_id), # currency decimal places
|
|
},
|
|
}
|
|
|
|
# Document type specific settings
|
|
if 'debit_origin_id' in self.env['account.move']._fields and invoice.debit_origin_id:
|
|
vals['document_type'] = 'debit_note'
|
|
vals['main_template'] = 'account_edi_ubl_cii.ubl_20_DebitNote'
|
|
vals['vals']['document_type_code'] = 383
|
|
elif invoice.move_type == 'out_refund':
|
|
vals['document_type'] = 'credit_note'
|
|
vals['main_template'] = 'account_edi_ubl_cii.ubl_20_CreditNote'
|
|
vals['vals']['document_type_code'] = 381
|
|
else: # invoice.move_type == 'out_invoice'
|
|
vals['document_type'] = 'invoice'
|
|
vals['main_template'] = 'account_edi_ubl_cii.ubl_20_Invoice'
|
|
vals['vals']['document_type_code'] = 380
|
|
|
|
return vals
|
|
|
|
def _export_invoice_constraints(self, invoice, vals):
|
|
constraints = self._invoice_constraints_common(invoice)
|
|
constraints.update({
|
|
'ubl20_supplier_name_required': self._check_required_fields(vals['supplier'], 'name'),
|
|
'ubl20_customer_name_required': self._check_required_fields(vals['customer'], 'name'),
|
|
'ubl20_commercial_customer_name_required': self._check_required_fields(vals['customer'].commercial_partner_id, 'name'),
|
|
'ubl20_invoice_name_required': self._check_required_fields(invoice, 'name'),
|
|
'ubl20_invoice_date_required': self._check_required_fields(invoice, 'invoice_date'),
|
|
})
|
|
return constraints
|
|
|
|
def _export_invoice(self, invoice):
|
|
vals = self._export_invoice_vals(invoice)
|
|
errors = [constraint for constraint in self._export_invoice_constraints(invoice, vals).values() if constraint]
|
|
xml_content = self.env['ir.qweb']._render(vals['main_template'], vals)
|
|
return etree.tostring(cleanup_xml_node(xml_content), xml_declaration=True, encoding='UTF-8'), set(errors)
|
|
|
|
def _get_document_type_code_vals(self, invoice, invoice_data):
|
|
"""Returns the values used for the `DocumentTypeCode` node"""
|
|
# To be overriden by custom format if required
|
|
return {'attrs': {}, 'value': None}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# IMPORT
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _import_fill_invoice_form(self, invoice, tree, qty_factor):
|
|
logs = []
|
|
|
|
if qty_factor == -1:
|
|
logs.append(_("The invoice has been converted into a credit note and the quantities have been reverted."))
|
|
|
|
# ==== partner_id ====
|
|
|
|
role = "Customer" if invoice.journal_id.type == 'sale' else "Supplier"
|
|
vat = self._find_value(f'//cac:Accounting{role}Party/cac:Party//cbc:CompanyID[string-length(text()) > 5]', tree)
|
|
phone = self._find_value(f'//cac:Accounting{role}Party/cac:Party//cbc:Telephone', tree)
|
|
mail = self._find_value(f'//cac:Accounting{role}Party/cac:Party//cbc:ElectronicMail', tree)
|
|
name = self._find_value(f'//cac:Accounting{role}Party/cac:Party//cbc:Name', tree)
|
|
country_code = self._find_value(f'//cac:Accounting{role}Party/cac:Party//cac:Country//cbc:IdentificationCode', tree)
|
|
self._import_retrieve_and_fill_partner(invoice, name=name, phone=phone, mail=mail, vat=vat, country_code=country_code)
|
|
|
|
# ==== currency_id ====
|
|
|
|
currency_code_node = tree.find('.//{*}DocumentCurrencyCode')
|
|
if currency_code_node is not None:
|
|
currency = self.env['res.currency'].with_context(active_test=False).search([
|
|
('name', '=', currency_code_node.text),
|
|
], limit=1)
|
|
if currency:
|
|
if not currency.active:
|
|
logs.append(_("The currency '%s' is not active.", currency.name))
|
|
invoice.currency_id = currency
|
|
else:
|
|
logs.append(_("Could not retrieve currency: %s. Did you enable the multicurrency option "
|
|
"and activate the currency?", currency_code_node.text))
|
|
|
|
# ==== invoice_date ====
|
|
|
|
invoice_date_node = tree.find('./{*}IssueDate')
|
|
if invoice_date_node is not None and invoice_date_node.text:
|
|
invoice.invoice_date = invoice_date_node.text
|
|
|
|
# ==== invoice_date_due ====
|
|
|
|
for xpath in ('./{*}DueDate', './/{*}PaymentDueDate'):
|
|
invoice_date_due_node = tree.find(xpath)
|
|
if invoice_date_due_node is not None and invoice_date_due_node.text:
|
|
invoice.invoice_date_due = invoice_date_due_node.text
|
|
break
|
|
|
|
# ==== Bank Details ====
|
|
|
|
bank_detail_nodes = tree.findall('.//{*}PaymentMeans')
|
|
bank_details = [bank_detail_node.findtext('{*}PayeeFinancialAccount/{*}ID') for bank_detail_node in bank_detail_nodes]
|
|
|
|
if bank_details:
|
|
self._import_retrieve_and_fill_partner_bank_details(invoice, bank_details=bank_details)
|
|
|
|
# ==== Reference ====
|
|
|
|
ref_node = tree.find('./{*}ID')
|
|
if ref_node is not None:
|
|
if invoice.is_sale_document(include_receipts=True) and invoice.quick_edit_mode:
|
|
invoice.name = ref_node.text
|
|
else:
|
|
invoice.ref = ref_node.text
|
|
|
|
# ==== Invoice origin ====
|
|
|
|
invoice_origin_node = tree.find('./{*}OrderReference/{*}ID')
|
|
if invoice_origin_node is not None:
|
|
invoice.invoice_origin = invoice_origin_node.text
|
|
|
|
# === Note/narration ====
|
|
|
|
narration = ""
|
|
note_node = tree.find('./{*}Note')
|
|
if note_node is not None and note_node.text:
|
|
narration += f"<p>{note_node.text}</p>"
|
|
|
|
payment_terms_node = tree.find('./{*}PaymentTerms/{*}Note') # e.g. 'Payment within 10 days, 2% discount'
|
|
if payment_terms_node is not None and payment_terms_node.text:
|
|
narration += f"<p>{payment_terms_node.text}</p>"
|
|
|
|
invoice.narration = narration
|
|
|
|
# ==== payment_reference ====
|
|
|
|
payment_reference_node = tree.find('./{*}PaymentMeans/{*}PaymentID')
|
|
if payment_reference_node is not None:
|
|
invoice.payment_reference = payment_reference_node.text
|
|
|
|
# ==== invoice_incoterm_id ====
|
|
|
|
incoterm_code_node = tree.find('./{*}TransportExecutionTerms/{*}DeliveryTerms/{*}ID')
|
|
if incoterm_code_node is not None:
|
|
incoterm = self.env['account.incoterms'].search([('code', '=', incoterm_code_node.text)], limit=1)
|
|
if incoterm:
|
|
invoice.invoice_incoterm_id = incoterm
|
|
|
|
# ==== invoice_line_ids: AllowanceCharge (document level) ====
|
|
|
|
logs += self._import_fill_invoice_allowance_charge(tree, invoice, qty_factor)
|
|
|
|
# ==== Prepaid amount ====
|
|
|
|
prepaid_node = tree.find('./{*}LegalMonetaryTotal/{*}PrepaidAmount')
|
|
logs += self._import_log_prepaid_amount(invoice, prepaid_node, qty_factor)
|
|
|
|
# ==== invoice_line_ids: InvoiceLine/CreditNoteLine ====
|
|
|
|
invoice_line_tag = 'InvoiceLine' if invoice.move_type in ('in_invoice', 'out_invoice') or qty_factor == -1 else 'CreditNoteLine'
|
|
for i, invl_el in enumerate(tree.findall('./{*}' + invoice_line_tag)):
|
|
invoice_line = invoice.invoice_line_ids.create({'move_id': invoice.id})
|
|
invl_logs = self._import_fill_invoice_line_form(invl_el, invoice_line, qty_factor)
|
|
logs += invl_logs
|
|
|
|
return logs
|
|
|
|
def _import_fill_invoice_line_form(self, tree, invoice_line, qty_factor):
|
|
logs = []
|
|
|
|
# Product.
|
|
invoice_line.product_id = self.env['product.product']._retrieve_product(
|
|
default_code=self._find_value('./cac:Item/cac:SellersItemIdentification/cbc:ID', tree),
|
|
name=self._find_value('./cac:Item/cbc:Name', tree),
|
|
barcode=self._find_value("./cac:Item/cac:StandardItemIdentification/cbc:ID[@schemeID='0160']", tree),
|
|
)
|
|
# Description
|
|
description_node = tree.find('./{*}Item/{*}Description')
|
|
name_node = tree.find('./{*}Item/{*}Name')
|
|
if description_node is not None:
|
|
invoice_line.name = description_node.text
|
|
elif name_node is not None:
|
|
invoice_line.name = name_node.text # Fallback on Name if Description is not found.
|
|
|
|
xpath_dict = {
|
|
'basis_qty': [
|
|
'./{*}Price/{*}BaseQuantity',
|
|
],
|
|
'gross_price_unit': './{*}Price/{*}AllowanceCharge/{*}BaseAmount',
|
|
'rebate': './{*}Price/{*}AllowanceCharge/{*}Amount',
|
|
'net_price_unit': './{*}Price/{*}PriceAmount',
|
|
'billed_qty': './{*}InvoicedQuantity' if invoice_line.move_id.move_type in ('in_invoice', 'out_invoice') or qty_factor == -1 else './{*}CreditedQuantity',
|
|
'allowance_charge': './/{*}AllowanceCharge',
|
|
'allowance_charge_indicator': './{*}ChargeIndicator',
|
|
'allowance_charge_amount': './{*}Amount',
|
|
'allowance_charge_reason': './{*}AllowanceChargeReason',
|
|
'allowance_charge_reason_code': './{*}AllowanceChargeReasonCode',
|
|
'line_total_amount': './{*}LineExtensionAmount',
|
|
}
|
|
|
|
# Taxes
|
|
inv_line_vals = self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor)
|
|
# retrieve tax nodes
|
|
tax_nodes = tree.findall('.//{*}Item/{*}ClassifiedTaxCategory/{*}Percent')
|
|
if not tax_nodes:
|
|
for elem in tree.findall('.//{*}TaxTotal'):
|
|
tax_nodes += elem.findall('.//{*}TaxSubtotal/{*}TaxCategory/{*}Percent')
|
|
return self._import_fill_invoice_line_taxes(tax_nodes, invoice_line, inv_line_vals, logs)
|
|
|
|
def _correct_invoice_tax_amount(self, tree, invoice):
|
|
""" The tax total may have been modified for rounding purpose, if so we should use the imported tax and not
|
|
the computed one """
|
|
# For each tax in our tax total, get the amount as well as the total in the xml.
|
|
for elem in tree.findall('.//{*}TaxTotal/{*}TaxSubtotal'):
|
|
percentage = elem.find('.//{*}TaxCategory/{*}Percent')
|
|
amount = elem.find('.//{*}TaxAmount')
|
|
if (percentage is not None and percentage.text is not None) and (amount is not None and amount.text is not None):
|
|
tax_percent = float(percentage.text)
|
|
# Compare the result with our tax total on the invoice, and apply correction if needed.
|
|
# First look for taxes matching the percentage in the xml.
|
|
taxes = invoice.line_ids.tax_line_id.filtered(lambda tax: tax.amount == tax_percent)
|
|
# If we found taxes with the correct amount, look for a tax line using it, and correct it as needed.
|
|
if taxes:
|
|
tax_total = float(amount.text)
|
|
tax_line = invoice.line_ids.filtered(lambda line: line.tax_line_id in taxes)[:1]
|
|
if tax_line:
|
|
sign = -1 if invoice.is_inbound(include_receipts=True) else 1
|
|
tax_line_amount = abs(tax_line.amount_currency)
|
|
if abs(tax_total - tax_line_amount) <= 0.05:
|
|
tax_line.amount_currency = tax_total * sign
|
|
|
|
# -------------------------------------------------------------------------
|
|
# IMPORT : helpers
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_import_document_amount_sign(self, tree):
|
|
"""
|
|
In UBL, an invoice has tag 'Invoice' and a credit note has tag 'CreditNote'. However, a credit note can be
|
|
expressed as an invoice with negative amounts. For this case, we need a factor to take the opposite
|
|
of each quantity in the invoice.
|
|
"""
|
|
if tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}Invoice':
|
|
amount_node = tree.find('.//{*}LegalMonetaryTotal/{*}TaxExclusiveAmount')
|
|
if amount_node is not None and float(amount_node.text) < 0:
|
|
return 'refund', -1
|
|
return 'invoice', 1
|
|
if tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2}CreditNote':
|
|
return 'refund', 1
|
|
return None, None
|