1195 lines
60 KiB
Python
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
|