import base64
import stdnum.uy
from lxml import etree
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import float_repr, float_round, cleanup_xml_node, format_amount, html2plaintext
def format_float(amount, digits=2):
if not amount:
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"),
("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 Uruware.",
)
# 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.journal_id.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")
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" 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()
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
"""
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 = "/"
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):
return self.l10n_uy_edi_document_id.action_update_dgi_state()
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
for k, line in enumerate(
self.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_note', 'line_section')),
start=1,
):
values = next(iter(tax_details['tax_details_per_record'][line]['tax_details'].values()))
line = values.get('records').pop()
# B4 IndFact
if self._l10n_uy_edi_is_expo_cfe():
invoice_ind = 10 # Exportación y asimiladas
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)
item_description = self._l10n_uy_edi_get_line_desc(line)
tax_included = values.get('group_tax_details')[0].get('price_include')
nom_item = (line.product_id.display_name or "-")[:80]
res.append({
"NroLinDet": k, # B1
"IndFact": invoice_ind, # B4
"NomItem": nom_item, # B7
"DscItem": item_description if item_description and item_description != nom_item else None, # B8
"Cantidad": 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
(line.quantity * line.price_unit - (line.price_total if tax_included else line.price_subtotal))
if line.discount else None,
"MontoItem": line.price_total if tax_included else line.price_subtotal, # B24
})
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()
for grouping_key, tax_dict in tax_details['tax_details'].items():
neto[grouping_key['tax_amount']] = tax_dict['base_amount_currency']
base[grouping_key['tax_amount']] = tax_dict['tax_amount_currency']
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] else None, # A119
"MntNetoIVATasaBasica": neto[22.0] if not expo_doc else None, # A117
"IVATasaBasica": 22 if not expo_doc and neto[22.0] 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
"MntTotal": self.amount_total, # A124
"CantLinDet": len(self.invoice_line_ids.filtered(lambda x: x.display_type == "product")), # A126
"MntPagar": self.amount_total, # A130
}
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
})
return res
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
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 Uruware Config
if msg := edi_model._is_connection_info_incomplete(self.company_id):
errors.append(msg)
# 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
used_rate = self._l10n_uy_edi_get_used_rate()
if self.currency_id and self.currency_id.name != "UYU" and 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"))
# CFE v.24: related CFE needs to be accepted by DGI
if related_doc.l10n_uy_edi_cfe_state != "accepted":
errors.append(_("To validate a DN/CN the related CFE (original document) needs to be Accepted by DGI"))
# 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
for line in lines:
errors += edi_model._check_field_size("B8_DscItem", self._l10n_uy_edi_get_line_desc(line), 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 '%s' (Id Invoice: %s)",
line.product_id.name or line.name, 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 '%s' (Id Invoice: %s)",
line.product_id.name, 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"))
# 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))
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_document_id.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)
addenda = addenda + "\n\n" + term_and_conditions if addenda else term_and_conditions
return addenda
def _l10n_uy_edi_get_line_desc(self, aml):
# B8 DscItem
item_description = ["{ %s }" % addenda.content if addenda.is_legend else addenda.content
for addenda in aml.l10n_uy_edi_addenda_ids]
if aml.name and aml.product_id.display_name != aml.name:
item_description.append(aml.name)
item_description = "\n".join(item_description)
return 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()
# We need to use abs to avoid error on Credit Notes (amount_total_signed is negative)
return abs(self.amount_total_signed) / self.amount_total
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_values):
tax = tax_values['tax_repartition_line'].tax_id
return {
'l10n_uy_tax_category': tax.l10n_uy_tax_category,
'tax_amount': tax.amount,
}
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),
"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("{}: {}").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.with_context(no_new_invoice=True).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
"""
rut_identification_type = self.env.ref('l10n_uy.it_rut')
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 == rut_identification_type):
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()