Files
enterprise-addons/l10n_uy_edi/models/account_move.py
T

1195 lines
60 KiB
Python

import base64
import stdnum.uy
import unicodedata
import logging
from datetime import datetime
from lxml import etree
from markupsafe import Markup
from odoo import _, api, fields, models, Command
from odoo.exceptions import ValidationError, UserError
from odoo.tools import float_repr, float_round, cleanup_xml_node, format_amount, html2plaintext
from odoo.tools.misc import formatLang
_logger = logging.getLogger(__name__)
def format_float(amount, digits=2, valid_zero=None):
if amount is None or (not amount and not valid_zero):
return None
return float_repr(float_round(amount, digits), digits)
class AccountMove(models.Model):
_inherit = "account.move"
l10n_uy_edi_document_id = fields.Many2one("l10n_uy_edi.document", string="Uruguay E-Invoice CFE", copy=False)
l10n_uy_edi_addenda_ids = fields.Many2many(
"l10n_uy_edi.addenda",
string="Addenda & Disclosure",
domain="[('type', 'in', ['issuer', 'receiver', 'cfe_doc', 'addenda'])]",
help="Addendas and Mandatory Disclosure to add on the CFE. They can be added either to the issuer, receiver,"
" or CFE document's additional information section, or to the addenda section. However, the item type should"
" not be set in this field; instead, it should be specified in the invoice lines.",
ondelete="restrict")
l10n_uy_edi_cfe_sale_mode = fields.Selection(
string="Sales Modality",
selection=[
("1", "General Regime"),
("2", "Consignment"),
("3", "Reviewable Price"),
("4", "Own goods to customs exclaves"),
("90", "General Regime - exportation of services"),
("91", "Export under Mandate"),
("99", "Other transactions"),
],
help="This field is used in the XML to create an Export e-Invoice",
)
l10n_uy_edi_cfe_transport_route = fields.Selection(
string="Transportation Route",
selection=[
("1", "Maritime"),
("2", "Air"),
("3", "Ground"),
("8", "N/A"),
("9", "Other"),
],
help="This field is used in the XML to create an Export e-Invoice",
)
# Related fields
l10n_uy_edi_cfe_uuid = fields.Char(related="l10n_uy_edi_document_id.uuid")
l10n_uy_edi_cfe_state = fields.Selection(related="l10n_uy_edi_document_id.state", store=True)
l10n_uy_edi_error = fields.Text(related="l10n_uy_edi_document_id.message")
l10n_uy_edi_journal_type = fields.Selection(related="journal_id.l10n_uy_edi_type")
# Compute fields
l10n_uy_edi_is_needed = fields.Boolean(compute="_compute_l10n_uy_edi_is_needed")
l10n_uy_edi_xml_attachment_id = fields.Many2one(
comodel_name="ir.attachment",
string="Uruguay E-Invoice XML",
compute="_compute_l10n_uy_edi_xml_attachment_id",
help="Uruguay: the most recent e-invoice XML returned by UCFE Provider.",
)
# Compute method
def _compute_name(self):
uy_invoices = self.filtered(
lambda m: m.l10n_uy_edi_is_needed
and m.state == "posted"
)
super(AccountMove, self - uy_invoices)._compute_name()
for move in uy_invoices:
if not move.name or move.name == "/":
move.name = "* %s" % move.id
@api.depends("l10n_uy_edi_cfe_state", "country_code", "move_type")
def _compute_l10n_uy_edi_is_needed(self):
for move in self:
move.l10n_uy_edi_is_needed = (
move.country_code == "UY"
and move.l10n_latam_use_documents
and move.journal_id.l10n_uy_edi_type == "electronic"
and move.is_sale_document()
and (not move.l10n_uy_edi_cfe_state or move.l10n_uy_edi_cfe_state == "error")
)
@api.depends("l10n_uy_edi_document_id.state", "move_type")
def _compute_l10n_uy_edi_xml_attachment_id(self):
for move in self:
doc = move.l10n_uy_edi_document_id
move.l10n_uy_edi_xml_attachment_id = (
doc.state == "accepted"
or move.is_sale_document()
) and doc.attachment_id
@api.depends("state", "l10n_uy_edi_document_id.state")
def _compute_show_reset_to_draft_button(self):
""" Hide reset to draft button if the edi document has been already processed by DGI """
# EXTEND from account
super()._compute_show_reset_to_draft_button()
for move in self.filtered(
lambda x: x.country_code == "UY" and x.is_sale_document(include_receipts=True) and x.l10n_uy_edi_document_id
):
move.show_reset_to_draft_button = move.l10n_uy_edi_document_id._can_edit()
# Constraints method
@api.constrains("l10n_uy_edi_addenda_ids")
def _l10n_uy_edi_check_addenda_type_item(self):
""" avoid letting the user add/create a disclosure of type Product/Service Detail in the Addenda
and Disclosure field. Product/Service Detail type can only be added in the invoice lines """
if self.l10n_uy_edi_addenda_ids.filtered(lambda x: x.type == "item"):
raise ValidationError(_("Product/Service Detail type Disclosure can only be added on invoice lines"))
# Extend existing methods
def action_post(self):
# EXTEND account
""" If journal configured to auto open the send and print wizard is set then
will do it. """
res = super().action_post()
self.filtered("l10n_uy_edi_error").l10n_uy_edi_document_id.unlink()
if any(self.journal_id.mapped("l10n_uy_edi_send_print")):
return self.action_send_and_print()
return res
def button_draft(self):
""" When an invoice sent to DGI returns errors (e.g., wrong partner or other data issues), users can reset it
to draft and make corrections. However, changing the partner triggers a validation error because the invoice
lacks a valid latam document number (only provided by DGI after processing). To avoid this, resetting the
invoice to draft clears the invoice name, allowing users to fix any errors without triggering the validation
"""
res = super().button_draft()
self.filtered(
lambda x: x.country_code == "UY" and
x.journal_id.l10n_uy_edi_type == "electronic" and
x.is_sale_document() and (
not x.l10n_uy_edi_document_id or
x.l10n_uy_edi_document_id.state not in ["received", "accepted", "rejected"]
)
).name = False
return res
def _is_manual_document_number(self):
# EXTEND l10n_latam_invoice_document
""" If we have an UY Sales Manual journal then the document number should always be manually add by the user """
if self.country_code == 'UY' and self.journal_id.type == 'sale' and self.journal_id.l10n_uy_edi_type == 'manual':
return True
return super()._is_manual_document_number()
def _get_last_sequence(self, relaxed=False, with_prefix=None):
# EXTEND account
""" l10n_latam_document_number is required in the view if no highest_name is set and so we provide a dummy one,
so the user does not have to set the document_number.
:return: string with the sequence, something like "E-ticket 0000001"""
res = super()._get_last_sequence(relaxed=relaxed, with_prefix=with_prefix)
if self.country_code == "UY" and not res and self.l10n_uy_edi_is_needed and self.l10n_latam_document_type_id:
res = "%s 00000000" % self.l10n_latam_document_type_id.doc_code_prefix
return res
def _get_l10n_latam_documents_domain(self):
# EXTEND l10n_latam_invoice_document
""" Only return the implemented electronic document types so far if the user want to issue from Odoo """
self.ensure_one()
domain = super()._get_l10n_latam_documents_domain()
if (
self.country_code == "UY"
and self.journal_id.type == "sale"
and self.journal_id.l10n_uy_edi_type == "electronic"
):
domain += [('code', 'in', ["101", "102", "103", "111", "112", "113", "121", "122", "123"])]
return domain
# Helpers
def l10n_uy_edi_action_update_dgi_state(self):
res = self.l10n_uy_edi_document_id.action_update_dgi_state()
to_cancel = self.filtered(lambda x: x.l10n_uy_edi_cfe_state == 'rejected')
to_post = self.filtered(lambda x: x.l10n_uy_edi_cfe_state == 'accepted' and x.state == 'cancel')
if to_cancel:
try:
to_cancel._check_fiscal_lock_dates()
to_cancel.line_ids._check_tax_lock_date()
except UserError as e:
_logger.warning(
"Cannot cancel rejected invoice(s): %s, error: %s", [(rec.id, rec.name) for rec in to_cancel], str(e)
)
else:
to_cancel.button_draft()
to_cancel.button_cancel()
_logger.info(
"Cancelled rejected invoice(s): %s", [(rec.id, rec.name) for rec in to_cancel]
)
# Send a message to the internal followers or to the Accounting Managers instead
# prompting manual review and resubmission
accounting_manager_partners = self.env.ref('account.group_account_manager').user_ids.mapped('partner_id')
for move in to_cancel:
internal_followers = move.message_partner_ids.filtered(
lambda x: x.user_ids and not x.user_ids.share)
partner_ids = (internal_followers or accounting_manager_partners).ids
move.message_post(
body=self.env._(
'The CFE has been rejected by DGI so we automatically cancel this record. Please, '
'you will need to manually check the reject reason and generate/send a new CFE with the fixes'),
message_type='comment',
subtype_xmlid='mail.mt_note',
partner_ids=partner_ids)
if to_post:
to_post.button_draft()
to_post.action_post()
_logger.info(
"Posted previously rejected invoice(s): %s", [(rec.id, rec.name) for rec in to_post]
)
return res
def l10n_uy_edi_action_download_preview_xml(self):
if self.l10n_uy_edi_document_id.attachment_id:
return self.l10n_uy_edi_document_id.action_download_file()
def _l10n_uy_edi_cfe_A_iddoc(self):
""" XML Section A (Encabezado) """
if self.invoice_line_ids.product_id.filtered(lambda x: x.type in ("consu", "product")):
incoterm = self.invoice_incoterm_id.code
else:
incoterm = "N/A"
self.l10n_uy_edi_cfe_transport_route = "8"
return {
"FchEmis": fields.Date.to_string(self.date), # A5
"MntBruto": # A10 - Tax Included
1 if self.line_ids.tax_ids.filtered(lambda x: x.l10n_uy_tax_category == "vat" and x.price_include)
else None,
"FmaPago": # A11: (1 cash, 2 credit). If the payment date is same as invoice date, and payment term only
# have one line then is cash, if not then credit
2 if self.invoice_date_due > self.invoice_date or len(self.invoice_payment_term_id.line_ids) > 1
else 1,
"FchVenc": fields.Date.to_string(self.invoice_date_due), # A12
"ClauVenta": self._l10n_uy_edi_is_expo_cfe() and incoterm or None, # A13
"ModVenta": self._l10n_uy_edi_is_expo_cfe() and self.l10n_uy_edi_cfe_sale_mode or None, # A14
"ViaTransp": self._l10n_uy_edi_is_expo_cfe() and self.l10n_uy_edi_cfe_transport_route or None, # A15
"InfoAdicionalDoc": self.l10n_uy_edi_document_id._get_legends("cfe_doc", self) or None, # A16
}
def _l10n_uy_edi_cfe_A_issuer(self):
""" XML Section A (Encabezado / Emisor) """
self.ensure_one()
edi_doc = self.l10n_uy_edi_document_id
return {
"RUCEmisor": stdnum.uy.rut.compact(self.company_id.vat), # A40
"RznSoc": self.company_id.name[:150], # A41
"CdgDGISucur": self.company_id.l10n_uy_edi_branch_code, # A47
"DomFiscal": self.company_id.partner_id._l10n_uy_edi_get_fiscal_address(), # A48
"Ciudad": self.company_id.city[:30], # A49
"Departamento": self.company_id.state_id.name[:30], # A50
"InfoAdicionalEmisor": edi_doc._get_legends("issuer", self) or None, # A51
}
def _l10n_uy_edi_cfe_A_receptor(self):
""" XML Section A (Encabezado / Receptor) """
self.ensure_one()
receptor_required = self.l10n_uy_edi_document_id._cfe_needs_partner_info(self)
# If we do not have the necessary receiver information, but the receiver information is not required,
# we will not send it.
if not self.partner_id.vat and not receptor_required:
return {}
doc_type = self.partner_id._l10n_uy_edi_get_doc_type()
# If we have the information available about the receiver, we send it no matter the case
# (this is what Uruware does)
return {
"TipoDocRecep": doc_type or None, # A60
"CodPaisRecep": self.partner_id.country_id.code or ("UY" if doc_type in [2, 3] else "99"), # A61
"DocRecep": self.commercial_partner_id.vat if doc_type in [1, 2, 3] else None, # A62
"DocRecepExt": self.commercial_partner_id.vat if doc_type not in [1, 2, 3] else None, # A62.1
"RznSocRecep": self.commercial_partner_id.name[:150] or None, # A63
"DirRecep": self.partner_id._l10n_uy_edi_get_fiscal_address() or None, # A64
"CiudadRecep": self.partner_id.city and self.partner_id.city[:30] or None, # A65
"DeptoRecep": self.partner_id.state_id and self.partner_id.state_id.name[:30] or None, # A66
"PaisRecep": self.partner_id.country_id and self.partner_id.country_id.name or None, # A66.1
"InfoAdicional": self.l10n_uy_edi_document_id._get_legends("receiver", self) or None, # A68
"CompraID": self.ref and self.ref[:50] or None, # A70
}
def _l10n_uy_edi_cfe_B_details(self, tax_details):
""" XML Section B (Detalle de Productos y Servicios)
Check the restriction on the maximum number of lines that we can report, launch a prior exception from Odoo
to avoid sending and receiving a rejection by DGI if we do not meet the specification.
:return: list of the prepare data of each line we are going to inform for the CFE """
self.ensure_one()
res = []
# NOTE: all amounts to be reported must be in the currency of the receipt not in Uruguayan pesos,
# that is why we use price_subtotal instead of another field
k = 1
for base_line in tax_details['base_lines']:
line = base_line['record']
if line.price_unit < 0:
continue
tax_info = tax_details['tax_details_per_record'][line]['tax_details'].values()
values = next(iter(tax_info)) if tax_info else False
tax_included = values['_tax_price_include'] if tax_info else False
invoice_ind = self._get_invoice_indicator(line, tax_details)
nom_item, item_description = self._l10n_uy_edi_get_line_nom_and_desc(line)
temp = {
"NroLinDet": k, # B1
"IndFact": invoice_ind, # B4
"NomItem": nom_item, # B7
"DscItem": item_description if item_description else None, # B8
"Cantidad": abs(line.quantity) if invoice_ind == 7 else line.quantity, # B9
"UniMed": line.product_uom_id.name[:4] if line.product_uom_id else "N/A", # B10
"PrecioUnitario": line.price_unit, # B11
"DescuentoPct": line.discount, # B12
"DescuentoMonto": # B13
(abs(line.quantity) * line.price_unit - (line.price_total if tax_included else line.price_subtotal))
if line.discount else None,
"MontoItem": abs(line.price_total) if tax_included else abs(line.price_subtotal), # B24
}
if invoice_ind == 5:
temp.update({}.fromkeys(['PrecioUnitario', 'DescuentoMonto', 'DescuentoPct'], 0.0))
res.append(temp)
k += 1
return res
def _l10n_uy_edi_cfe_C_totals(self, tax_details):
""" XML Section C (SUBTOTALES INFORMATIVOS) """
self.ensure_one()
currency_name = self.currency_id.name if self.currency_id else self.company_id.currency_id.name
expo_doc = self._l10n_uy_edi_is_expo_cfe()
neto = {10.0: 0.0, 22.0: 0.0, 0.0: 0.0}
base = neto.copy()
neto_keys = neto.keys()
for grouping_key, tax_dict in tax_details['tax_details'].items():
key = grouping_key['_tax_amount'] if grouping_key['_tax_amount'] in neto_keys else 'reduced_tax'
neto[key] = neto.get(key, 0) + tax_dict['base_amount_currency']
base[key] = base.get(key, 0) + tax_dict['tax_amount_currency']
nf_amount = sum(
self.invoice_line_ids.filtered(lambda x: x.move_id._is_downpayment() or x.quantity < 0).mapped('price_subtotal')
)
res = {
"TpoMoneda": currency_name, # A110
"TpoCambio": None if currency_name == "UYU" else self._l10n_uy_edi_get_used_rate(), # A111
"MntExpoyAsim": self.amount_total if expo_doc else None, # A113
"MntNoGrv": neto[0.0] if not expo_doc else None, # A112
"MntNetoIvaTasaMin": neto[10.0] if not expo_doc else None, # A116
"IVATasaMin": 10 if not expo_doc and neto[10.0] or self._is_downpayment() else None, # A119
"MntNetoIVATasaBasica": neto[22.0] if not expo_doc else None, # A117
"MntNetoIVAOtra": neto.get('reduced_tax'), # A118
"IVATasaBasica": 22 if not expo_doc and neto[22.0] or self._is_downpayment() else None, # A120
"MntIVATasaMin": base[10.0] if not expo_doc else None, # A121
"MntIVATasaBasica": base[22.0] if not expo_doc else None, # A122
"MntIVAOtra": base.get('reduced_tax'), # A123
"MntTotal": self.amount_total - nf_amount if nf_amount else self.amount_total, # A124
"CantLinDet": len(self.invoice_line_ids.filtered(lambda x: x.display_type == "product" and x.price_unit >= 0 or x.move_id._is_downpayment())), # A126
"MontoNF": nf_amount or None,
"MntPagar": self.amount_total, # A130
}
return res
def _l10n_uy_edi_cfe_D_global_discount(self, tax_details):
"""XML Section D (Descuntos y recargos)
Filter global discount lines. This section is used to specify discounts or surcharges that apply to the total
document amount, without the need to detail them item by item.
:return: list of the prepare data of each line we are going to inform for the CFE """
self.ensure_one()
res = []
discount_base_lines = [bl for bl in tax_details['base_lines'] if bl['record'].price_unit < 0]
for k, base_line in enumerate(discount_base_lines, 1):
line = base_line['record']
invoice_ind = self._get_invoice_indicator(line, tax_details)
res.append({
"NroLinDR": k, # D1
"TpoMovDR": "D", # D2
"TpoDR": 1, # D3
"GlosaDR": line.name[:100] if line.name else _('Discount'), # D5
"ValorDR": abs(base_line['tax_details']['raw_total_excluded_currency']), # D6
"IndFactDR": invoice_ind, # D7
})
return res
def _l10n_uy_edi_cfe_F_reference(self):
""" XML Section F (REFERENCE INFORMATION). If is a debit/credit note cfe then we need to inform the reference tag """
res = []
if self.l10n_latam_document_type_id.internal_type in ["credit_note", "debit_note"]:
related_doc = self._l10n_uy_edi_found_related_cfe()
for k, related_cfe in enumerate(related_doc, 1):
cfe_serie, cfe_number = self.l10n_uy_edi_document_id._get_doc_parts(related_cfe)
res.append({
"NroLinRef": k, # F1
"TpoDocRef": int(related_cfe.l10n_latam_document_type_id.code), # F3
"Serie": cfe_serie, # F4
"NroCFERef": cfe_number, # F5
"FechaCFEref": related_cfe.invoice_date, # F7
"MntCFEref": related_cfe.amount_total, # F8
"TpoMonedaRef": related_cfe.currency_id.name, # F9
"TpoCambioRef": related_cfe._l10n_uy_edi_get_used_rate(), # F10
})
return res
def _get_invoice_indicator(self, line, tax_details):
# B4 IndFact
if self._l10n_uy_edi_is_expo_cfe():
invoice_ind = 10 # Exportación y asimiladas
elif self._is_downpayment():
invoice_ind = 6
elif line.quantity < 0:
invoice_ind = 7 # Discount
else:
ind_code = {
0.0: 1, # 1: Exento de IVA
10.0: 2, # 2: Gravado a Tasa Mínima
22.0: 3, # 3: Gravado a Tasa Básica
}
# IMPORTANT: By the moment, this is working for one VAT tax per move lines
invoice_ind = ind_code.get(line.tax_ids.amount, 4) # 4: Otra Tasa IVA
tax_included = set(line.tax_ids.mapped("price_include"))
# We made this in separate if because expo invoices can also have entrega gratuita
if line.currency_id.is_zero(line.price_total if tax_included else line.price_subtotal):
invoice_ind = 5 # Entrega Gratuita
return invoice_ind
def _l10n_uy_edi_check_move(self):
""" Need to fullfil next conditions:
* Check receiver has a valid identification number
* Check that we do not sent any tax on exportation invoices
* Check that domestic CFE always has a vat tax per line
* Check that Doc type is set and is a valid one
* Check that the Partner address info is set if required for the doc type
* Check if discounts with X tax then should be another line not discount that share the same tax
return: an error list if the minimal conditions to be a valid CFE does not fulfill """
self.ensure_one()
errors = []
if not (self.company_id.country_code == "UY" and self.l10n_latam_use_documents):
return errors
edi_model = self.env["l10n_uy_edi.document"]
# Check Issuer data
if config_errors := self.company_id._l10n_uy_edi_validate_company_data():
errors.append(_(
"To create the CFE document first complete your company data (%(company_name)s):\n\t- %(errors)s",
errors="\n\t- ".join(config_errors),
company_name=self.company_id.name))
# Check receiver has a valid identification number
try:
self.partner_id._check_vat()
except ValidationError as exp:
errors.append(_("Problem with Receiver identification number: %(exp_msg)s", exp_msg=str(exp)))
# Check currency configuration works to be able to report to DGI
uy_edi_currencies = [
# partial iso 4217
"ARS", "BRL", "CAD", "CLP", "CNY", "COP", "EUR", "JPY", "MXN", "PYG", "PEN", "USD", "UYU", "VEF",
# other_currencies
"UYI", "UYR",
]
currency_names = self.currency_id.mapped("name") + self.company_id.currency_id.mapped("name")
for currency_name in currency_names:
if currency_name not in uy_edi_currencies:
errors.append(_("The currency does not exist on DGI currencies table %s", currency_name))
# Rates Configuration
if self.currency_id and self.currency_id.name != "UYU":
used_rate = self._l10n_uy_edi_get_used_rate()
if used_rate <= 0.0:
errors.append(_(
"Not valid Currency Rate, need to be greater than 0 to be accepted by DGI"
" (%(used_rate)s)", used_rate=used_rate)
)
# If debit or credit, we need to ensure that the original related document exists and is accepted by DGI
if self.l10n_latam_document_type_id.internal_type in ["credit_note", "debit_note"]:
related_doc = self._l10n_uy_edi_found_related_cfe()
if not related_doc:
errors.append(_("To validate a DN/CN the original document should be informed"))
if not int(related_doc.l10n_latam_document_type_id.code):
errors.append(_("To validate a DN/CN the original document should be informed and it should be electronic"))
# For e-Ticket and related DN/CN max lines <= 700
lines = self.invoice_line_ids.filtered(lambda x: x.display_type not in ("line_section", "line_note"))
if self.l10n_latam_document_type_id.code in [101, 102, 103, 131, 132, 133] and len(lines) > 700:
errors.append(_("For e-Ticket and related DN and CN you can only report up to 700 lines"))
elif len(lines) > 200: # Other CFE types: Max 200
errors.append(_("For this type of CFE you can only report up to 200 lines"))
# We check that not other taxes are being used (not supported for EDI)
if not_supported_taxes := lines.tax_ids.filtered(lambda x: x.l10n_uy_tax_category != "vat"):
errors.append(_(
"Not valid Uruguayan tax, only VAT taxes are supported (%(taxes_name)s)",
taxes_name=', '.join(not_supported_taxes.mapped('name'))),
)
# Check expo conditions
expo_doc = int(self.l10n_latam_document_type_id.code) in [121, 122, 123]
# Where: 121 "Export e-Invoice" / 122 "Export e-Invoice Credit Note" / 123 "Export e-Invoice Debit Note"
# Ensure required fields for expo invoices
if expo_doc:
missing_expo_fields = []
has_products = self.invoice_line_ids.product_id.filtered(lambda x: x.type in ("consu", "product"))
if has_products and not self.invoice_incoterm_id:
missing_expo_fields.append("Incoterm")
if not self.l10n_uy_edi_cfe_sale_mode:
missing_expo_fields.append("Sales Modality")
if has_products and not self.l10n_uy_edi_cfe_transport_route:
missing_expo_fields.append("Transportation Route")
if missing_expo_fields:
errors.append(_(
"To report an export invoice you must fill the next fields. "
"You can indicate this value in the Other Information tab: "
" \n\t * %s", "\n\t * ".join(missing_expo_fields)))
# Check lines
# When I create an invoice with Anticipo directly, the line is not downpayment so we have an error -
# All lines should have a VAT tax -
for line in lines.filtered(lambda x: not x.move_id._is_downpayment() and x.quantity > 0):
errors += edi_model._check_field_size("B8_DscItem", self._l10n_uy_edi_get_line_nom_and_desc(line)[1], 1000)
# We check that there is one and o nly one vat tax per line
vat_taxes = line.tax_ids.filtered(lambda x: x.l10n_uy_tax_category == "vat")
if len(vat_taxes) != 1:
errors.append(_(
"All lines should have a VAT tax (only one per line). "
"Check line '%(line_name)s' (Id Invoice: %(move_id)s)",
line_name=line.product_id.name or line.name,
move_id=line.move_id.id,
))
elif expo_doc:
if line.tax_ids.amount != 0.0:
errors.append(_(
"Export CFE can only have 0%% vat taxes. Check line '%(line_name)s' (Id Invoice: %(move_id)s)",
line_name=line.product_id.name,
move_id=line.move_id.id,
))
# Check the type of vat taxes used (all included or not included)
taxes = self.line_ids.tax_ids.filtered(lambda t: t.l10n_uy_tax_category == "vat")
tax_included = set(taxes.mapped("price_include"))
if tax_included and len(tax_included) != 1:
errors.append(_("You cannot combine included and not included taxes on the same invoice"))
# Downpayments invoices or deduct downlapyment lines should not have taxes
if self._is_downpayment() and self.invoice_line_ids.tax_ids:
errors.append(_("Downpayment invoices should not have any taxes, please remove any taxes from"
" the downpayment line to continue."))
deduct_dp_lines = self.invoice_line_ids.filtered(lambda l: l.quantity < 0 and l._get_downpayment_lines())
if deduct_dp_lines.filtered(lambda l: l.tax_ids):
errors.append(_("Downpayment lines should not have any taxes, please remove then to continue"))
# Discount lines must always be associated to another line sharing the same taxes.
if self.is_invoice():
AccountTax = self.env['account.tax']
base_lines, _tax_lines = self._get_rounded_base_and_tax_lines()
# Downpayment deduction lines are not discount lines — exclude them from this validation.
check_base_lines = [bl for bl in base_lines if bl['record'] not in deduct_dp_lines]
for base_line in check_base_lines:
has_taxes = base_line['tax_ids']
is_neg_qty = base_line['quantity'] < 0
if (
base_line['tax_details']['raw_total_excluded_currency'] < 0
and has_taxes
and not is_neg_qty
):
base_line['special_type'] = 'global_discount'
# Return of merchandise: negative lines reduce quantity instead of being added as a discount.
check_base_lines = AccountTax._dispatch_return_of_merchandise_lines(check_base_lines, self.company_id)
AccountTax._squash_return_of_merchandise_lines(check_base_lines, self.company_id)
# Global discount: spread the discount equally across the matching lines.
check_base_lines = AccountTax._dispatch_global_discount_lines(check_base_lines, self.company_id)
AccountTax._squash_global_discount_lines(check_base_lines, self.company_id)
remaining_negative_lines = [
bl for bl in check_base_lines
if bl['tax_details']['raw_total_excluded_currency'] < 0
]
for bl in remaining_negative_lines:
errors.append(_(
"Discount line '%(line_name)s' must be associated to another line having the same taxes (%(tax_names)s).",
line_name=bl['record'].name,
tax_names=', '.join(bl['record'].tax_ids.mapped('name')),
))
# Do not let negative quantities, only can be done if line is a donwpayment deduct
negative_lines = self.line_ids.filtered(lambda l: l.quantity < 0 and not l._get_downpayment_lines())
if negative_lines:
errors.append(_("You cannot create lines with negative quantities (except for down payments deducts)."))
# Check the receiver info depending on the doc type
edi_model = self.env["l10n_uy_edi.document"]
document_type = int(self.l10n_latam_document_type_id.code)
cond_e_fact = document_type in [111, 112, 113, 141, 142, 143]
# Where:
# 111 e-Invoice
# 112 e-Invoice Credit Note
# 113 e-Invoice Debit Note
# 141 e-Invoice Sale By Third Party
# 142 e-Invoice Sale By Third Party Credit Note
# 143 e-Invoice Sale By Third Party Debit Note
cond_e_ticket = document_type in [101, 102, 103, 131, 132, 133]
# Where:
# 101 e-Ticket
# 102 e-Ticket Credit Note
# 103 e-Ticket Debit Note
# 131 e-Ticket Sale By Third Party
# 132 e-Ticket Sale By Third Party Credit Note
# 133 e-Ticket Sale By Third Party Debit Note
cond_e_fact_expo = self._l10n_uy_edi_is_expo_cfe()
receptor_required = edi_model._cfe_needs_partner_info(self)
doc_type = self.partner_id._l10n_uy_edi_get_doc_type()
min_amount = edi_model._get_minimum_legal_amount(self.company_id, self.date)
if min_amount == 1.0:
errors.append(_("You need to have UYI rate before validating invoices"))
if not doc_type:
errors.append(_("%(dtype)s is not an Uruguayan Identification Type or "
"a Generic one (VAT, Passport, or Foreign ID). You need to select a valid ID to be able "
"to invoice", dtype=self.partner_id.l10n_latam_identification_type_id.name))
# Validations to have all the receiver data if the receiver is required
if receptor_required:
if not self.partner_id.l10n_latam_identification_type_id:
errors.append(_("The partner of the CFE needs to have an Identification Type"))
if cond_e_fact_expo or cond_e_fact or (cond_e_ticket and receptor_required):
if not all([self.partner_id.street, self.partner_id.city, self.partner_id.state_id,
self.partner_id.country_id, self.partner_id.vat]):
msg = _("You need to fill in the receiver details: address, city, province, country and ID number")
if cond_e_ticket:
msg += _(
"\n\nNOTE: This is required since the e-Ticket exceeds the minimum amount."
"\nMinimum amount = 5000 Uruguayan Indexed Unit (>%(min_amount)s)",
min_amount=format_amount(self.env, min_amount, self.company_currency_id),
)
errors.append(msg)
# Check field length of special tags that have mandatory disclosure included and warn the user if the text
# length is more that the MAX (this way we avoid that mandatory disclosure can result incomplete on the XML,
# we need this validation because the mandatory disclosure have legal implication, and we need to ensure
# that all the needed text is in the CFE)
errors += edi_model._check_field_size(
"A51_InfoAdicionalEmisor", edi_model._get_legends("issuer", self) or None, 150)
errors += edi_model._check_field_size(
"A16_InfoAdicionalDoc", edi_model._get_legends("cfe_doc", self) or None, 150)
errors += edi_model._check_field_size(
"A68_InfoAdicional", edi_model._get_legends("receiver", self) or None, 150)
# Check that the document type has been implemented, if not do not let the user create the doc
if not edi_model._get_cfe_tag(self):
errors.append(_("This CFE is not implemented yet %(doc_name)s", doc_name=self.l10n_latam_document_type_id.display_name))
# If found related document then it should be electronic one (should have serie and number)
if related_cfe := self._l10n_uy_edi_found_related_cfe():
cfe_serie, cfe_number = self.l10n_uy_edi_document_id._get_doc_parts(related_cfe)
if not cfe_serie or not cfe_number:
errors.append(_("The related CFE (%s) should be electronic", related_cfe.name))
return errors
def _l10n_uy_edi_cron_update_dgi_status(self, batch_size=10):
res = self.search([("l10n_uy_edi_cfe_state", "=", "received"), ("journal_id.type", "=", "sale")])
res[:batch_size].l10n_uy_edi_action_update_dgi_state()
if len(res) > batch_size:
self.env.ref("l10n_uy_edi.ir_cron_update_dgi_state")._trigger()
def _l10n_uy_edi_dummy_validation(self):
""" When we want to skip DGI and validate only in Odoo """
self.l10n_uy_edi_document_id.state = "accepted"
self.write({
"l10n_latam_document_number": "DE%07d" % self.id,
"ref": "*DEMO",
})
return self._l10n_uy_edi_get_preview_xml()
def _l10n_uy_edi_found_related_cfe(self):
""" Return the related/origin CFE of a given CFE. """
self.ensure_one()
res = self.env["account.move"]
if self.l10n_latam_document_type_id.internal_type == "credit_note":
res = self.reversed_entry_id
elif self.l10n_latam_document_type_id.internal_type == "debit_note":
res = self.debit_origin_id
if res:
if res.l10n_uy_edi_cfe_state != "accepted":
res.l10n_uy_edi_document_id.action_update_dgi_state()
return res
def _l10n_uy_edi_get_addenda(self):
""" return string with the addenda """
addenda = self.l10n_uy_edi_document_id._get_legends("addenda", self)
if self.narration:
term_and_conditions = html2plaintext(self.narration).strip()
addenda = addenda + "\n" + term_and_conditions if addenda else term_and_conditions
return self._l10n_uy_edi_clean_non_ascii_chars(addenda)
def _l10n_uy_edi_clean_non_ascii_chars(self, text):
"""Deletes non-ASCII characters from strings."""
if isinstance(text, str):
return ''.join(char for char in text if (ord(char) <= 127) or unicodedata.category(char) == 'Ll' or unicodedata.category(char) == 'Lu')
return text
def _l10n_uy_edi_get_line_nom_and_desc(self, aml):
"""
Generate the item name and description for a given account move line (aml).
This method extracts and formats the name and description of a product or
account move line based on the provided data. It handles cases where the
product is not defined, includes multilingual support, and appends additional
information from addenda if present.
Returns a tuple containing:
- nom_item (str): The formatted name of the item (up to 80 characters).
- description (str): The description of the item, including any
additional addenda content if applicable.
"""
# B7 NomItem, B8 DscItem
# NomItem is a mandatory field, so if there is no product_id, we use
# the description of the line (aml.name).
if not aml.product_id:
nom_item = aml.name and aml.name[:80] or '-'
description = aml.name and aml.name[80:] or ''
else:
# If the product is defined, we use its name as the item name
# and the line name as the description.
display_name = aml.with_context(lang=aml.partner_id.lang).product_id.display_name \
if aml.partner_id.lang else aml.product_id.display_name
nom_item = display_name[:80]
# The description has the product name, so we do not include that part
description = display_name[80:]
if aml.name:
description += aml.name.replace(display_name, '').replace('\n', '')
if aml.l10n_uy_edi_addenda_ids:
adenda = [" {%s}" % addenda.content if addenda.is_legend else " " + addenda.content for addenda in aml.l10n_uy_edi_addenda_ids]
description += str(*adenda)
return nom_item, description
def _l10n_uy_edi_get_pdf(self):
""" Call endpoint to get PDF file from Uruware (Standard Representation)
return: dictionary with {"errors": str(): "pdf_file"attachment object } """
res = {}
if "out" not in self.move_type or self.journal_id.l10n_uy_edi_type != "electronic":
return {"errors": _("Only can get the legal representation of the CFE for customer electronic invoices")}
result = self.l10n_uy_edi_document_id._get_pdf()
if file_content := result.get("file_content"):
pdf_file = self.env["ir.attachment"].create({
"res_model": "account.move",
"res_id": self.id,
"res_field": "invoice_pdf_report_file",
"name": (self.name or _("INV")).replace("/", "_") + ".pdf",
"type": "binary",
"datas": file_content,
})
res["pdf_file"] = pdf_file
return res
def _l10n_uy_edi_get_preview_xml(self):
self.ensure_one()
edi_doc = self.l10n_uy_edi_document_id
edi_doc.attachment_id.res_field = False
xml_file = self.env["ir.attachment"].create({
"res_model": "l10n_uy_edi.document",
"res_field": "attachment_file",
"res_id": edi_doc.id,
"name": edi_doc._get_xml_attachment_name(),
"type": "binary",
"datas": base64.b64encode(self._l10n_uy_edi_get_xml_content().encode()),
})
edi_doc.invalidate_recordset(["attachment_id", "attachment_file"])
return xml_file
def _l10n_uy_edi_get_used_rate(self):
self.ensure_one()
UYU = self.env.ref("base.UYU")
res = 0.0
if self.currency_id != UYU:
res = self.currency_id._convert(
1.0, UYU, self.company_id, self.date or fields.Date.today(), round=False)
return res
def _l10n_uy_edi_get_xml_content(self):
""" Create the CFE xml structure and validate it
:return: string the xml content to send to DGI """
self.ensure_one()
def grouping_key_generator(base_line, tax_data):
tax = tax_data['tax']
return {
'l10n_uy_tax_category': tax.l10n_uy_tax_category,
'_tax_amount': tax.amount,
'_tax_price_include': tax.price_include,
}
tax_details = self._prepare_invoice_aggregated_taxes(grouping_key_generator=grouping_key_generator)
template_name = "l10n_uy_edi." + self.l10n_uy_edi_document_id._get_cfe_tag(self) + "_template"
cfe = self.env["ir.qweb"]._render(template_name, values={
"cfe": self,
"IdDoc": self._l10n_uy_edi_cfe_A_iddoc(),
"emisor": self._l10n_uy_edi_cfe_A_issuer(),
"receptor": self._l10n_uy_edi_cfe_A_receptor(),
"item_detail": self._l10n_uy_edi_cfe_B_details(tax_details),
"totals_detail": self._l10n_uy_edi_cfe_C_totals(tax_details),
"global_discounts": self._l10n_uy_edi_cfe_D_global_discount(tax_details),
"referencia_lines": self._l10n_uy_edi_cfe_F_reference(),
"format_float": format_float,
})
return etree.tostring(cleanup_xml_node(cfe)).decode()
def _l10n_uy_edi_is_expo_cfe(self):
""" True of False if the CFE is an Export type """
self.ensure_one()
return int(self.l10n_latam_document_type_id.code) in [121, 122, 123]
def _l10n_uy_edi_prepare_req_data(self):
""" Creating dictionary with the request to generate a DGI EDI document """
self.ensure_one()
edi_doc = self.l10n_uy_edi_document_id
xml_content = self._l10n_uy_edi_get_xml_content()
req_data = {
"Uuid": edi_doc.uuid,
"TipoCfe": int(self.l10n_latam_document_type_id.code),
"HoraReq": edi_doc.request_datetime.strftime("%H%M%S"),
"FechaReq": edi_doc.request_datetime.date().strftime("%Y%m%d"),
"CfeXmlOTexto": xml_content}
if addenda := self._l10n_uy_edi_get_addenda():
req_data["Adenda"] = addenda
return req_data
def _l10n_uy_edi_send(self):
""" Create CFE Document and send it to Uruware """
for move in self:
move.l10n_uy_edi_document_id.filtered(lambda doc: doc.state == "error").unlink()
edi_doc = self.env['l10n_uy_edi.document'].create({
"move_id": move.id,
"uuid": self.env['l10n_uy_edi.document']._get_uuid(move),
})
move.l10n_uy_edi_document_id = edi_doc
if move.company_id.l10n_uy_edi_ucfe_env == "demo":
attachments = move._l10n_uy_edi_dummy_validation()
msg = _("This CFE has been generated in DEMO Mode. It is considered as accepted and it won\"t be sent to DGI.")
else:
request_data = move._l10n_uy_edi_prepare_req_data()
result = edi_doc._send_dgi(request_data)
edi_doc._update_cfe_state(result)
response = result.get("response")
if edi_doc.message:
move.message_post(
body=Markup("<font style='color:Tomato;'><strong>{}:</strong></font> <i>{}</<i>").format(("ERROR"), edi_doc.message)
)
elif edi_doc.state in ["received", "accepted"]:
# If everything is ok we save the return information
move.l10n_latam_document_number = \
response.findtext(".//{*}Serie") + "%07d" % int(
response.findtext(".//{*}NumeroCfe"))
msg = response.findtext(".//{*}MensajeRta", "")
msg += _("The electronic invoice was created successfully")
if response is not None:
attachments = move._l10n_uy_edi_update_xml_and_pdf_file(response)
if edi_doc.state in ["received", "accepted", "rejected"]:
move.message_post(
body=msg,
attachment_ids=attachments.ids if attachments else False,
)
def _l10n_uy_edi_update_xml_and_pdf_file(self, response):
""" Clean up the pdf and xml fields. Create new ones with the response """
self.ensure_one()
res_files = self.env["ir.attachment"]
edi_doc = self.l10n_uy_edi_document_id
self.invoice_pdf_report_id.res_field = False
edi_doc.attachment_id.res_field = False
xml_content = response.findtext(".//{*}XmlCfeFirmado")
if xml_content:
res_files = self.env["ir.attachment"].create({
"res_model": "l10n_uy_edi.document",
"res_field": "attachment_file",
"res_id": edi_doc.id,
"name": edi_doc._get_xml_attachment_name(),
"type": "binary",
"datas": base64.b64encode(
xml_content.encode() if self.l10n_uy_edi_cfe_state in ["received", "accepted"]
else self._l10n_uy_edi_get_xml_content().encode()
),
})
edi_doc.invalidate_recordset(["attachment_id", "attachment_file"])
# If the record has been posted automatically print and attach the legal report to the record.
if self.l10n_uy_edi_cfe_state and self.l10n_uy_edi_cfe_state != "error":
pdf_result = self._l10n_uy_edi_get_pdf()
if pdf_file := pdf_result.get("pdf_file"):
# make sure latest PDF shows to the right of the chatter
pdf_file.register_as_main_attachment(force=True)
self.invalidate_recordset(fnames=["invoice_pdf_report_id", "invoice_pdf_report_file"])
res_files |= pdf_file
if errors := pdf_result.get("errors"):
msg = _("Error getting the PDF file: %s", errors)
self.l10n_uy_edi_error = (self.l10n_uy_edi_error or "") + msg
self.message_post(body=msg)
else:
self._l10n_uy_edi_get_preview_xml()
return res_files
def _compute_l10n_latam_document_type(self):
"""
The following considerations apply for determining document types based on the partner's identification:
RUT/RUC (Uruguay): Automatically select e-factura.
Other documents (Example: CI, PAS, NIE, NIFE, etc.): Automatically select e-ticket
"""
if uy_einvoices := self.filtered(lambda m: m.country_code == 'UY' and
m.move_type in ('out_invoice', 'out_refund') and
m.state == 'draft' and
not m.posted_before and
m.journal_id.l10n_uy_edi_type == 'electronic' and
m.partner_id.l10n_latam_identification_type_id == self.env.ref('l10n_uy.it_rut') and
not m.debit_origin_id):
uy_einvoices.l10n_latam_document_type_id = self.env.ref('l10n_uy.dc_e_inv')
super(AccountMove, self - uy_einvoices)._compute_l10n_latam_document_type()
def _get_import_file_type(self, file_data):
""" Identify Uruguayan EDI files. """
# EXTENDS 'account'
if (
file_data['xml_tree'] is not None
and b"CdgDGISucur>" in file_data['raw']
):
return 'l10n_uy_edi'
return super()._get_import_file_type(file_data)
def _unwrap_attachment(self, file_data, recurse=True):
""" Divide a CFEEnvio file into constituent CFEs and create a new attachment for each CFE after the first. """
# EXTENDS 'account'
if file_data['import_file_type'] != 'l10n_uy_edi':
return super()._unwrap_attachment(file_data, recurse)
embedded = self._split_xml_into_new_attachments(file_data, tag='{*}CFE_Adenda')
if embedded and recurse:
embedded.extend(self._unwrap_attachments(embedded, recurse=True))
return embedded
def _get_edi_decoder(self, file_data, new=False):
""" User can upload xml files. The xml file must belong to the journal`s company`s. """
# EXTENDS 'account'
if file_data['import_file_type'] == 'l10n_uy_edi':
def decoder(invoice, file_data, new=False):
invoice._l10n_uy_edi_complete_cfe_from_xml(file_data['xml_tree'])
return {
'priority': 20,
'decoder': decoder,
}
return super()._get_edi_decoder(file_data, new)
def _l10n_uy_edi_complete_cfe_from_xml(self, xml_tree, l10n_uy_idreq=False):
""" Here the vendor bills are completed and synchronized through the Uruware notification request or from
xml uploaded on vendor bill journal on invoicing dashboard. This method will create the attachment with the
given xml and also will try to get the odoo pdf from Uruware and attach it in the move. If there is a
difference between the move total amount in Odoo and the move total amount in the XML, then a message is posted
in the document informing that situation.
1) Set the move date.
2) Set the partner, it is created if no partner exists with the same vat in the database.
3) Set the currency.
4) Set the move lines.
5) Set the move due date.
6) Set the document type.
7) Set the document number.
8) Update move DGI state.
:param move: The account.move record
:param file_data: The file obtained from the synchronization.
:param l10n_uy_idreq: the id from the response_600 when the document is
created by 'UY: Create vendor bills (sync from Uruware)' cron. """
self.ensure_one()
latam_document = self._l10n_uy_edi_get_cfe_document_type(xml_tree)
is_cobranza = xml_tree.findtext('.//{*}IndCobPropia')
if not latam_document or latam_document.code in ['124', '181', '182', '224', '281', '282'] or is_cobranza:
latam_document_name = latam_document.name if latam_document else xml_tree.findtext('.//{*}TipoCFE') or xml_tree.findtext('.//{*}TipoCfe') or 'undefined'
if is_cobranza:
latam_document_name += " (Cobranza)"
msg = _("For now it is not possible to create %(latam_document_name)s documents",
latam_document_name=latam_document_name)
self.message_post(body=msg)
return
errors = []
partner_vat_RUC = xml_tree.findtext(".//{*}RUCEmisor")
date_format = "%Y-%m-%d"
# Currency and select/create partner.
currency = self.env['res.currency'].with_context(active_test=False).search(
[('name', '=', xml_tree.findtext(".//{*}TpoMoneda"))],
limit=1
)
# Create partner if does not exists.
doc_number = xml_tree.findtext(".//{*}Serie") + (xml_tree.findtext(".//{*}NumeroCfe") or xml_tree.findtext(".//{*}Nro")).zfill(7)
if not self.l10n_uy_edi_document_id: # This means that the XML was dragged and dropped
self.l10n_uy_edi_document_id._create_edi_move(
xml_tree,
move=self,
company=self.company_id,
doc_type=latam_document,
l10n_uy_idreq=str(self.id) + '-manual'
)
self.env["ir.attachment"].create({
"name": f"CFE_{doc_number}_manual.xml",
"res_model": "l10n_uy_edi.document",
"res_id": self.l10n_uy_edi_document_id.id,
"res_field": "attachment_file",
"type": "binary",
"datas": base64.b64encode(etree.tostring(xml_tree))})
self.partner_id = self.l10n_uy_edi_document_id._get_partner_from_xml(xml_tree, partner_vat_RUC)
self.move_type = latam_document._l10n_uy_edi_get_move_type()
self.l10n_latam_document_type_id = latam_document
self.l10n_latam_document_number = doc_number
self.currency_id = currency
self.invoice_date = datetime.strptime(xml_tree.findtext(".//{*}FchEmis"), date_format).date()
serie, number = self.l10n_uy_edi_document_id._get_doc_parts(self)
self.l10n_uy_edi_document_id._create_pdf_vendor_bill(self, req_data_pdf={
"rut": self.company_id.vat,
"rutRecibido": partner_vat_RUC,
"tipoCfe": self.l10n_latam_document_type_id.code,
"serieCfe": serie,
"numeroCfe": number
})
try:
self.line_ids = self._l10n_uy_edi_vendor_prepare_lines(
line_nodes=xml_tree.findall(".//{*}Item"),
tax_included=xml_tree.findtext(".//{*}MntBruto") == '1',
global_discounts_surcharges=xml_tree.findall(".//{*}DRG_Item"),
)
except Exception as e: # noqa: BLE001
self.invoice_line_ids = False
self.l10n_latam_document_type_id = latam_document
self.l10n_latam_document_number = doc_number
errors.append(str(e))
if fecha_vto := xml_tree.findtext(".//{*}FchVenc"):
self.invoice_date_due = datetime.strptime(fecha_vto, date_format).date()
self.l10n_uy_edi_action_update_dgi_state()
# Validation of the move total amounts
amount_total = self.amount_total
if xml_tree.findtext(".//{*}MntPagar"):
formatted_xml_amount_total = float(xml_tree.findtext(".//{*}MntPagar"))
if not self.currency_id.is_zero(amount_total - formatted_xml_amount_total):
formatted_amount_total = formatLang(self.env, formatted_xml_amount_total, currency_obj=self.currency_id)
errors.append(_(
"There is a difference between the move total amount in Odoo and the move XML. Odoo: %(amount_total)s XML: %(formatted_amount_total)s.",
amount_total=amount_total, formatted_amount_total=formatted_amount_total
))
if errors:
self.message_post(body="\n - ".join(errors))
def _l10n_uy_edi_get_cfe_document_type(self, xml_tree):
""" :return: latam document type in Odoo that represented the XML CFE. """
# Until now we are not supporting the creation of e-Resguardos and e-Remitos
l10n_latam_document_type_id = xml_tree.findtext('.//{*}TipoCFE') or xml_tree.findtext('.//{*}TipoCfe')
return self.env["l10n_latam.document.type"].search([
("code", "=", l10n_latam_document_type_id),
("country_id.code", "=", "UY"),
], limit=1)
def _l10n_uy_edi_get_tax_not_implemented_description(self, ind_fact):
""" There are some taxes not implemented for Uruguay, so when move lines are created and if those ones don`t have
ind_fact (Indicador de facturación) 1, 2 or 3 then is concatenated the name of the tax not implemented with the
name of the line. """
data = {
"1": "Exento de IVA",
"2": "Gravado a Tasa Mínima",
"3": "Gravado a Tasa Básica",
"4": "Gravado a Otra Tasa/IVA sobre fictos",
"5": "Entrega gratuita",
"6": "No facturable",
"7": "No facturable negativo",
"8": "Ítem a rebajar en e-remitos", # Only for e-remitos
"9": "Ítem a anular en resguardos", # Only for e-resguardos
"10": "Exportación y asim",
"11": "Impuesto percibido",
"12": "IVA en suspenso",
# Only for e-Boleta de entrada
"13": "Ítem vendido por un no contribuyente",
"14": "Ítem vendido por un contribuyente IVA mínimo, Monotributo o Monotributo MIDES",
"15": "Ítem vendido por un contribuyente IMEBA",
"16": "Sólo para ítems vendidos por contribuyentes con obligación IVA mínimo, Monotributo o Monotributo MIDES",
}
return data.get(ind_fact, _("UNKNOWN INDICATOR %(ind_fact)s", ind_fact=ind_fact))
def _l10n_uy_edi_vendor_prepare_lines(self, line_nodes, tax_included, global_discounts_surcharges=False):
""" Prepare the lines to create vendor bills lines in Odoo from the given xml.
There are some taxes not implemented for Uruguay, so when line nodes don`t have 10% or 22% or 0% EXEMPT tax then
is concatenated the name of the tax not implemented with the name of the line.
:return list of invoice line ids Commands. """
self.ensure_one()
invoice_line_ids_commands = []
# Basic rates: excempt from vat, taxed at minimum rate and taxed at basic rate
l10n_uy_basic_rates = ["1", "2", "3"]
for line_node in line_nodes:
ind_fact = line_node.findtext(".//{*}IndFact")
domain_tax = self._l10n_uy_edi_get_domain_line_tax(ind_fact, self.company_id, tax_included)
tax_item = self.env["account.tax"].search(domain_tax, limit=1)
price_unit = line_node.findtext(".//{*}PrecioUnitario")
name = line_node.findtext(".//{*}NomItem")
if ind_fact not in l10n_uy_basic_rates:
name = f"{name} ({self._l10n_uy_edi_get_tax_not_implemented_description(ind_fact)})"
tax_ids = [Command.set(tax_item.ids)] if ind_fact in l10n_uy_basic_rates else []
invoice_line_ids_commands.append(Command.create({
"name": name,
"quantity": float(line_node.findtext(".//{*}Cantidad")),
"price_unit": (1 if ind_fact != "7" else -1) * float(price_unit),
"tax_ids": tax_ids,
}))
if raw_discount_amount := float(line_node.findtext(".//{*}DescuentoMonto", default=False)):
invoice_line_ids_commands.append(Command.create({
"name": name,
"quantity": 1,
"price_unit": -raw_discount_amount,
"tax_ids": tax_ids,
}))
for disc_surch in global_discounts_surcharges:
ind_fact = disc_surch.findtext(".//{*}IndFactDR")
tax_item = self.env["account.tax"].search(domain_tax, limit=1)
valor_dr = float(disc_surch.findtext(".//{*}ValorDR"))
tipo_dr = disc_surch.findtext(".//{*}TpoDR")
tipo_mov_dr = disc_surch.findtext(".//{*}TpoMovDR")
if tipo_dr == '1':
price_unit = valor_dr
else:
price_unit = valor_dr * (1 / 100)
if tipo_mov_dr != 'R':
price_unit = -price_unit
invoice_line_ids_commands.append(Command.create({
"name": disc_surch.findtext(".//{*}GlosaDR"),
"quantity": 1,
"price_unit": price_unit,
"tax_ids": [Command.set(tax_item.ids)] if ind_fact in l10n_uy_basic_rates else []
}))
return invoice_line_ids_commands
def _l10n_uy_edi_get_domain_line_tax(self, ind_fact, company, tax_included=False):
amount_by_ind = {"1": 0, "2": 10, "3": 22}
domain_tax = [*self.env['account.tax']._check_company_domain(company), ("country_code", "=", "UY"), ("type_tax_use", "=", "purchase"), ('l10n_uy_tax_category', '=', 'vat')]
if amount := [('amount', '=', amount_by_ind[ind_fact])] if amount_by_ind.get(ind_fact) is not None else False:
domain_tax += amount
if tax_included:
if company.account_price_include == 'tax_included':
domain_tax += [("price_include_override", 'in', ('tax_included', 'default'))]
else:
domain_tax += [("price_include_override", '=', 'tax_included')]
return domain_tax