734 lines
33 KiB
Python
734 lines
33 KiB
Python
import base64
|
|
import logging
|
|
from lxml import etree
|
|
import re
|
|
from requests.exceptions import Timeout, ConnectionError, HTTPError
|
|
import textwrap
|
|
|
|
from odoo import _, api, fields, models, tools
|
|
|
|
from odoo.tools import float_compare
|
|
from odoo.tools.zeep import Client, Settings
|
|
from odoo.tools.zeep.wsse.username import UsernameToken
|
|
|
|
|
|
RESPONSE_CODE_TO_STATE = {
|
|
# Irreversible states
|
|
"00": "accepted", # Petición aceptada y procesada
|
|
"06": "accepted", # CFE observado por DGI
|
|
"11": "received", # CFE aceptado por UCFE, en espera de respuesta de DGI
|
|
"05": "rejected", # CFE rechazado por DGI (Anulado). Do not sent again to UCFE neither create CREDIT NOTES
|
|
|
|
# Errors
|
|
"01": "error", # Petición denegada
|
|
|
|
# Related to configuration of UCFE. Please fix it and then try to send CFE again
|
|
"03": "error", # Comercio inválido
|
|
"89": "error", # Terminal inválida
|
|
|
|
# UCFE does not receive the CFE
|
|
"12": "error", # Requerimiento inválido
|
|
"94": "error",
|
|
"99": "error", # Sesión no iniciada
|
|
|
|
"30": "error", # Error en formato (Format error on the query)
|
|
"31": "error", # Error en formato de CFE (Fortmat error of the xml)
|
|
"96": "error", # Error en sistema (UFCE Internal error). Example: Bugs, down database, disk full, etc.
|
|
}
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class L10n_Uy_EdiDocument(models.Model):
|
|
_name = 'l10n_uy_edi.document'
|
|
_description = "Electronic Fiscal Document (CFE - UY)"
|
|
_rec_name = "l10n_latam_document_number"
|
|
|
|
uuid = fields.Char(
|
|
string="Key or UUID CFE",
|
|
copy=False,
|
|
readonly=True,
|
|
help="Unique identification per CFE in UCFE: concatenation of the model name initials plus the record id",
|
|
)
|
|
request_datetime = fields.Datetime(default=fields.Datetime.now, required=True, readonly=True)
|
|
state = fields.Selection(
|
|
string="CFE Status",
|
|
selection=[
|
|
("received", "Waiting response from DGI"),
|
|
("accepted", "CFE Accepted by DGI"),
|
|
("rejected", "CFE Rejected by DGI"),
|
|
("error", "ERROR")
|
|
],
|
|
copy=False,
|
|
readonly=True,
|
|
help="State of the electronic document",
|
|
)
|
|
move_id = fields.Many2one("account.move", readonly=True)
|
|
message = fields.Text(
|
|
string="Uruguay E-Invoice Error",
|
|
copy=False,
|
|
readonly=True,
|
|
help="error details for CFEs in the 'error' state.",
|
|
)
|
|
# Attachment
|
|
attachment_id = fields.Many2one(
|
|
"ir.attachment",
|
|
compute=lambda self: self._compute_linked_attachment_id("attachment_id", "attachment_file"),
|
|
depends=["attachment_file"],
|
|
)
|
|
attachment_file = fields.Binary(copy=False, attachment=True)
|
|
|
|
# Related fields from origin record
|
|
l10n_latam_document_type_id = fields.Many2one(
|
|
"l10n_latam.document.type", "Document Type", related=False, compute="_compute_from_origin"
|
|
)
|
|
l10n_latam_document_number = fields.Char(related=False, compute="_compute_from_origin")
|
|
company_id = fields.Many2one("res.company", related=False, compute="_compute_from_origin")
|
|
partner_id = fields.Many2one("res.partner", related=False, compute="_compute_from_origin")
|
|
|
|
# Compute methods
|
|
|
|
@api.depends('move_id.l10n_latam_document_number', 'move_id.l10n_latam_document_type_id', 'move_id.company_id', 'move_id.partner_id')
|
|
def _compute_from_origin(self):
|
|
for doc in self:
|
|
doc.l10n_latam_document_number = False
|
|
doc.l10n_latam_document_type_id = False
|
|
doc.company_id = False
|
|
doc.partner_id = False
|
|
if doc.move_id:
|
|
doc.l10n_latam_document_number = doc.move_id.l10n_latam_document_number
|
|
doc.l10n_latam_document_type_id = doc.move_id.l10n_latam_document_type_id
|
|
doc.company_id = doc.move_id.company_id
|
|
doc.partner_id = doc.move_id.partner_id
|
|
|
|
def _compute_linked_attachment_id(self, attachment_field, binary_field):
|
|
"""Helper to retrieve Attachment from Binary fields
|
|
This is needed because fields.Many2one("ir.attachment") makes all
|
|
attachments available to the user.
|
|
"""
|
|
attachments = self.env["ir.attachment"].search([
|
|
("res_model", "=", self._name),
|
|
("res_id", "in", self.ids),
|
|
("res_field", "=", binary_field)
|
|
])
|
|
edi_vals = {att.res_id: att for att in attachments}
|
|
for edi_doc in self:
|
|
edi_doc[attachment_field] = edi_vals.get(edi_doc._origin.id, False)
|
|
|
|
# Action Methods
|
|
|
|
def action_download_file(self):
|
|
""" Be able to download the XML file related to this EDI document
|
|
|
|
* If document received/accepted it will be the valid CFE
|
|
* If document is in error state will download the preview of the XML that we are trying to send to
|
|
Uruware-DGI """
|
|
self.ensure_one()
|
|
return {
|
|
"type": "ir.actions.act_url",
|
|
"url": f"/web/content/{self.attachment_id.id}?download=true",
|
|
}
|
|
|
|
def action_update_dgi_state(self):
|
|
""" Call endpoint that return the updated state of the EDI document on DGI.
|
|
Make a query to UCFE in order to know if DGI give us a definitive state for the invoice (Used only for all the
|
|
electronic invoices that are state waiting DGI response). Only applies to customer invoices
|
|
|
|
Will return None and the result will be update the cfe_state field (error field
|
|
if applies)"""
|
|
for edi_doc in self:
|
|
if edi_doc.move_id.journal_id.type == 'sale':
|
|
result = edi_doc._ucfe_inbox("360", {"Uuid": edi_doc.uuid})
|
|
else:
|
|
document_number = re.search(r"([A-Z]*)([0-9]*)", edi_doc.move_id.l10n_latam_document_number).groups()
|
|
result = edi_doc._ucfe_inbox("650", {
|
|
"TipoCfe": edi_doc.move_id.l10n_latam_document_type_id_code,
|
|
"Serie": document_number[0],
|
|
"NumeroCfe": document_number[1],
|
|
"RutEmisor": edi_doc.move_id.partner_id.vat})
|
|
edi_doc._update_cfe_state(result)
|
|
|
|
# Extended methods
|
|
|
|
def unlink(self):
|
|
self.attachment_id.unlink()
|
|
return super().unlink()
|
|
|
|
def _get_fields_to_detach(self):
|
|
# EXTENDS account
|
|
fields_list = super()._get_fields_to_detach()
|
|
fields_list.append('attachment_file')
|
|
return fields_list
|
|
|
|
# Helpers
|
|
|
|
def _can_edit(self):
|
|
""" The CFE cannot be modified once processed by DGI """
|
|
self.ensure_one()
|
|
return self.state not in ["accepted", "rejected", "received"]
|
|
|
|
@api.model
|
|
def _cfe_needs_partner_info(self, move):
|
|
""" Whether the partner address is required.
|
|
For e-ticket, if the amount is less than 5000 UYI, it's optional. """
|
|
move.ensure_one()
|
|
document_type = int(move.l10n_latam_document_type_id.code)
|
|
min_amount = self._get_minimum_legal_amount(move.company_id, move.date)
|
|
return (
|
|
document_type in [101, 102, 103, 131, 132, 133]
|
|
and float_compare(abs(move.amount_total_signed), min_amount, precision_digits=2) == 1
|
|
)
|
|
|
|
@api.model
|
|
def _validate_credentials(self, company):
|
|
""" Make a ECHO test to UCFE to see if the server is running and that the environment
|
|
params have been properly configured """
|
|
error = self.env["l10n_uy_edi.document"]._is_connection_info_incomplete(company)
|
|
if error:
|
|
return error
|
|
|
|
company_missing_data = company._l10n_uy_edi_validate_company_data()
|
|
if company_missing_data:
|
|
return _(
|
|
"Not able to check credentials, first complete your company data:\n\t- %(errors)s",
|
|
errors="\n\t- ".join(company_missing_data),
|
|
)
|
|
|
|
now = fields.Datetime.now()
|
|
result = self._ucfe_inbox("820", {"FechaReq": now.date().strftime("%Y%m%d"), "HoraReq": now.strftime("%H%M%S")})
|
|
if errors := result.get("errors"):
|
|
return "\n".join(errors)
|
|
return ""
|
|
|
|
@api.model
|
|
def _check_field_size(self, field_name, res, limit):
|
|
errors = []
|
|
if res and len(res) > limit:
|
|
errors.append(_(
|
|
"We cannot generate the CFE because the field length is not valid.\nCheck if disclosure/addenda are"
|
|
" being applied.\n\n * Name of the field: %(xml_tag)s (%(xml_tag_len)s)\n * Content:"
|
|
" (%(value_len)s)\n %(value_content)s",
|
|
xml_tag=field_name, xml_tag_len=limit, value_len=len(res), value_content=res))
|
|
return errors
|
|
|
|
def _get_origin_record(self):
|
|
self.ensure_one()
|
|
return self.move_id
|
|
|
|
@api.model
|
|
def _get_cfe_tag(self, move):
|
|
move.ensure_one()
|
|
cfe_code = int(move.l10n_latam_document_type_id.code)
|
|
if cfe_code in [101, 102, 103, 201]:
|
|
return "eTck"
|
|
elif cfe_code in [111, 112, 113]:
|
|
return "eFact"
|
|
elif cfe_code in [121, 122, 123]:
|
|
return "eFact_Exp"
|
|
else:
|
|
return False
|
|
|
|
@api.model
|
|
def _get_doc_parts(self, record):
|
|
""" Return list [serie, number] of the give CFE. If not valid then return [False, False]"""
|
|
res = re.findall(r"([A-Z]{1,2})[-]*([0-9]{1,8})", record.l10n_latam_document_number)
|
|
return res[-1] if res else [False, False]
|
|
|
|
@api.model
|
|
def _get_legends(self, addenda_type, move_id):
|
|
""" This method check return the legends and info to be used per xml tag. also will automatically add ̱̰{ } to
|
|
the legends when needed, which indicates Uruware the presence
|
|
of Mandatory Disclosure
|
|
Return type: string """
|
|
res = []
|
|
addendas = move_id.l10n_uy_edi_addenda_ids.filtered(lambda x: x.type == addenda_type)
|
|
for addenda in addendas:
|
|
content = addenda.content.strip()
|
|
res.append("{ %s }" % content if addenda.is_legend else content)
|
|
return "\n".join(res)
|
|
|
|
@api.model
|
|
def _get_minimum_legal_amount(self, company, date):
|
|
""" Converts 50000 UYI in the company currency """
|
|
return self.env.ref("base.UYI")._convert(50000, company.currency_id, company=company, date=date)
|
|
|
|
def _get_pdf(self):
|
|
""" Connect to Uruware with the info of CFE and return the corresponding PDF file
|
|
Legal representation.
|
|
return: {"errors"; strg(), "file_content": bytes string file content}"""
|
|
res = {}
|
|
document_number = re.search(r"([A-Z]*)([0-9]*)", self.l10n_latam_document_number).groups()
|
|
req_data = {
|
|
"rut": self.company_id.partner_id.vat,
|
|
"tipoCfe": int(self.l10n_latam_document_type_id.code),
|
|
"serieCfe": document_number[0],
|
|
"numeroCfe": document_number[1],
|
|
}
|
|
report_params, extra_params = self._get_report_params()
|
|
req_data.update(extra_params)
|
|
|
|
result = self._ucfe_query(report_params, req_data)
|
|
response = result.get("response")
|
|
|
|
if response is not None:
|
|
res.update({"file_content": response.findtext(".//{*}" + report_params + "Result").encode()})
|
|
|
|
if result.get("errors"):
|
|
res.update({"errors": result.get("errors")})
|
|
|
|
return res
|
|
|
|
def _get_report_params(self):
|
|
""" Print the default representation of the PDF report with extra params when applicable.
|
|
Extra params available:
|
|
1. ("adenda", "true") to print the adenda in a separate sheet if it is longer than 6 lines.
|
|
2. ("reporte", "ingles") In case document is an e-ticket or e-factura expo or their respective CN and DN,
|
|
if the partner's configured language is not Spanish it will print the report both in spanish and english.
|
|
"""
|
|
|
|
available_doc_types = (
|
|
self.env.ref('l10n_uy.dc_e_ticket') |
|
|
self.env.ref('l10n_uy.dc_cn_e_ticket') |
|
|
self.env.ref('l10n_uy.dc_dn_e_ticket') |
|
|
self.env.ref('l10n_uy.dc_e_inv_exp') |
|
|
self.env.ref('l10n_uy.dc_cn_e_inv_exp') |
|
|
self.env.ref('l10n_uy.dc_dn_e_inv_exp')
|
|
)
|
|
addenda = self._get_origin_record()._l10n_uy_edi_get_addenda()
|
|
parameters = {}
|
|
|
|
if addenda:
|
|
# The addenda (e.g. terms and conditions) is added in a small box at the bottom of the standard PDF report.
|
|
# This can only accommodate roughly 6 lines of 140 characters.If the addenda exceeds that, the remainder is
|
|
# cut off, which is problematic for mandatory disclosures etc.
|
|
max_chars_per_line = 140
|
|
max_lines_without_addenda = 6
|
|
wrapped = textwrap.wrap(addenda, width=max_chars_per_line, replace_whitespace=False)
|
|
|
|
# The resulting list will preserve newlines from the addenda because of replace_whitespace=False, join to
|
|
# create the final string. Count the lines of the final string and request a dedicated addenda page if
|
|
# needed, adding the parameter to the report.
|
|
if len("\n".join(wrapped).splitlines()) > max_lines_without_addenda:
|
|
parameters["adenda"] = "true"
|
|
|
|
if (
|
|
self.l10n_latam_document_type_id.code in available_doc_types.mapped('code')
|
|
and self.partner_id.lang
|
|
and not self.partner_id.lang.startswith("es_")
|
|
):
|
|
parameters["reporte"] = "ingles"
|
|
|
|
if parameters:
|
|
return "ObtenerPdfConParametros", {
|
|
"nombreParametros": {"string": list(parameters.keys())},
|
|
"valoresParametros": {"string": list(parameters.values())}
|
|
}
|
|
|
|
return "ObtenerPdf", {}
|
|
|
|
def _get_ucfe_username(self, company):
|
|
return re.sub("[^0-9]", "", company.vat) if company.vat else False
|
|
|
|
def _get_uuid(self, move):
|
|
""" Uruware UUID to identify the edi document and also A4.1 (NroInterno) DGI field. Spec (V24) ALFA50.
|
|
We did not make it as default value because we need the move to set """
|
|
res = move._name + "-" + str(move.id)
|
|
if move.company_id.l10n_uy_edi_ucfe_env == "testing":
|
|
res = "am" + str(move.id) + "-" + self.env.cr.dbname
|
|
return res[:50]
|
|
|
|
def _get_ws_url(self, ws_endpoint, company):
|
|
"""
|
|
Get the Uruware endpoint to be called, or False if we are in demo mode.
|
|
The endpoints are read from the config parameters:
|
|
* `l10n_uy_edi.l10n_uy_edi_ucfe_inbox_url`
|
|
* `l10n_uy_edi.l10n_uy_edi_ucfe_query_url`
|
|
|
|
:param ws_endpoint: "inbox" or "query"
|
|
:param company: res.company
|
|
"""
|
|
if company.l10n_uy_edi_ucfe_env == "demo":
|
|
return False
|
|
elif company.l10n_uy_edi_ucfe_env == 'production':
|
|
base_url = "https://prod6109.ucfe.com.uy/"
|
|
else:
|
|
base_url = "https://odootest.ucfe.com.uy/"
|
|
|
|
if ws_endpoint == "inbox":
|
|
url = self.env["ir.config_parameter"].sudo().get_param(
|
|
key="l10n_uy_edi.l10n_uy_edi_ucfe_inbox_url",
|
|
default=base_url + "inbox115/cfeservice.svc",
|
|
)
|
|
pattern = r"https://.*\.ucfe\.com\.uy/inbox.*/cfeservice\.svc"
|
|
elif ws_endpoint == "query":
|
|
url = self.env["ir.config_parameter"].sudo().get_param(
|
|
key="l10n_uy_edi.l10n_uy_edi_ucfe_query_url",
|
|
default=base_url + "query116/webservicesfe.svc",
|
|
)
|
|
pattern = r"https://.*\.ucfe\.com\.uy/query.*/webservicesfe\.svc"
|
|
else:
|
|
url = pattern = None
|
|
|
|
return url if re.match(pattern, url, re.IGNORECASE) is not None else False
|
|
|
|
def _get_xml_attachment_name(self):
|
|
if self and self.move_id.company_id.l10n_uy_edi_ucfe_env == "demo":
|
|
return "demo-cfe-%s.xml" % self.l10n_latam_document_number
|
|
if self.state in ["received", "accepted"]:
|
|
return f"CFE_{self.l10n_latam_document_number}.xml"
|
|
return "preview-cfe-move-%s.xml" % self.move_id.id
|
|
|
|
@api.model
|
|
def _is_connection_info_incomplete(self, company):
|
|
""" False if everything is ok, Message if there is a problem or something missing """
|
|
|
|
field_data = company.fields_get([])
|
|
missing_info = []
|
|
for field in (
|
|
"l10n_uy_edi_ucfe_env",
|
|
"l10n_uy_edi_ucfe_password",
|
|
"l10n_uy_edi_ucfe_commerce_code",
|
|
"l10n_uy_edi_ucfe_terminal_code",
|
|
):
|
|
if not company[field]:
|
|
missing_info.append(field_data[field]["string"])
|
|
inbox_url = self._get_ws_url("inbox", company)
|
|
if not inbox_url:
|
|
missing_info.append(_("UCFE Provider Inbox URL"))
|
|
query_url = self._get_ws_url("query", company)
|
|
if not query_url:
|
|
missing_info.append(_("UCFE Provider Query URL"))
|
|
username = self._get_ucfe_username(company)
|
|
if not username:
|
|
missing_info.append(_("UCFE Provider Username"))
|
|
|
|
if missing_info:
|
|
return _(
|
|
"Incomplete Data to connect to UCFE Provider on company %(company)s: Please complete the UCFE data to test "
|
|
"the connection: %(missing)s",
|
|
company=company.name,
|
|
missing=", ".join(missing_info),
|
|
)
|
|
|
|
return False
|
|
|
|
@api.model
|
|
def _process_response(self, soap_response, errors):
|
|
response_tree = False
|
|
if errors and soap_response is None:
|
|
return {"errors": errors}
|
|
if soap_response is None:
|
|
return {"errors": _("No response")}
|
|
|
|
if soap_response.content is None:
|
|
return {"errors": _("EMPTY response")}
|
|
|
|
try:
|
|
response_tree = etree.fromstring(soap_response.content)
|
|
except etree.LxmlError as exp:
|
|
return {"errors": _("Error processing the response %(exp_rep)s", exp_rep=str(exp))}
|
|
|
|
# Capture any other errors in the connection
|
|
if response_tree is not None:
|
|
error_code = response_tree.findtext(".//{*}ErrorCode")
|
|
if error_code and int(error_code):
|
|
error_msg = response_tree.findtext(".//{*}ErrorMessage")
|
|
errors.append(_("Response Error - Code: %(code)s %(msg)s", code=error_code, msg=error_msg or ""))
|
|
if fault_string := response_tree.findtext(".//{*}faultstring"):
|
|
errors.append(_("Fault Error - %(msg)s", msg=fault_string))
|
|
|
|
return {"response": response_tree, "errors": errors}
|
|
|
|
def _send_dgi(self, request_data):
|
|
""" Call endpoint that lets us post a DGI Invoice (310 - Signature and sending of CFE (individual)) """
|
|
self.ensure_one()
|
|
return self._ucfe_inbox("310", request_data)
|
|
|
|
def _ucfe_inbox(self, msg_type, extra_req):
|
|
""" Call Operation on UCFE inbox webservice
|
|
:param msg_type: integer that represents the query we are going to call. For instance:
|
|
360 - Check CFE State
|
|
310 - Create CFE on DGI (send)
|
|
820 - Check Credentials
|
|
:returns: dictionary ({"response" etree obj }, "errors": str()) """
|
|
now = fields.Datetime.now()
|
|
company = extra_req.get('company') or self.company_id or self.env.company
|
|
data = {
|
|
"CodComercio": company.sudo().l10n_uy_edi_ucfe_commerce_code,
|
|
"CodTerminal": company.sudo().l10n_uy_edi_ucfe_terminal_code,
|
|
"RequestDate": now.replace(microsecond=0).isoformat(),
|
|
"Tout": "30000",
|
|
"Req": {
|
|
"TipoMensaje": msg_type,
|
|
"CodComercio": company.sudo().l10n_uy_edi_ucfe_commerce_code,
|
|
"CodTerminal": company.sudo().l10n_uy_edi_ucfe_terminal_code,
|
|
"IdReq": 1,
|
|
**extra_req,
|
|
},
|
|
}
|
|
return self._ucfe_ws_call(company, "inbox", "Invoke", [data])
|
|
|
|
def _ucfe_query(self, method, req_data):
|
|
""" Call Query on UCFE Query Webservices """
|
|
company = self.company_id or self.env.company
|
|
return self._ucfe_ws_call(company, "query", method, **req_data)
|
|
|
|
def _ucfe_ws_call(self, company, endpoint, method, *args, **kwargs):
|
|
response = None
|
|
errors = []
|
|
|
|
if error := self._is_connection_info_incomplete(company):
|
|
# An error is possible if the company does not have credentials or if they are incorrect
|
|
return {'response': None, "errors": [error]}
|
|
url = self._get_ws_url(endpoint, company)
|
|
if url and not url.endswith("?wsdl"):
|
|
url += "?wsdl"
|
|
try:
|
|
username_token = UsernameToken(self._get_ucfe_username(company), company.l10n_uy_edi_ucfe_password)
|
|
client = Client(url, wsse=username_token, settings=Settings(raw_response=True))
|
|
if args:
|
|
response = client.service[method](*args)
|
|
else:
|
|
response = client.service[method](**kwargs)
|
|
except (Timeout, ConnectionError, HTTPError) as exp:
|
|
errors.append(_("There was a problem with the connection with Uruware: %s", repr(exp)))
|
|
|
|
return self._process_response(response, errors)
|
|
|
|
def _update_cfe_state(self, result):
|
|
""" Update the CFE State and update the error message field if applies.
|
|
It depends on the Uruware/DGI state, response(CodRta)
|
|
|
|
If CFE have been accepted, received or rejected cannot be sent again to UCFE
|
|
because they cannot be changed (they have been already sent to DGI) """
|
|
errors = result.get("errors")
|
|
if errors:
|
|
self.write({
|
|
'state': "error",
|
|
'message': "\n - ".join(errors),
|
|
})
|
|
else:
|
|
response = result.get("response")
|
|
if response is not None:
|
|
ucfe_result_code = response.findtext(".//{*}CodRta")
|
|
self.state = RESPONSE_CODE_TO_STATE.get(ucfe_result_code, "error")
|
|
if self.state in ["error", "rejected"]:
|
|
result_msg = response.findtext(".//{*}MensajeRta")
|
|
self.message = _("CODE %(code)s: %(msg)s", code=ucfe_result_code, msg=result_msg)
|
|
elif self.state in ["received", "accepted"]:
|
|
self.message = False
|
|
|
|
def _create_partner_from_notification(self, xml_tree, partner_vat_RUC):
|
|
"""DEPRECATED: Use _get_partner_from_xml instead."""
|
|
return self._get_partner_from_xml(xml_tree, partner_vat_RUC)
|
|
|
|
def _get_partner_from_xml(self, xml_tree, partner_vat_RUC):
|
|
"""Select partner if exists or create partner from vendor bill XML data. """
|
|
partner = self.env["res.partner"]._retrieve_partner(vat=partner_vat_RUC, company=self.company_id)
|
|
state_id = None
|
|
if departamento := xml_tree.findtext(".//{*}Departamento"):
|
|
state_id = self.env["res.country.state"].search([("name", "ilike", departamento), ("country_id.code", "=", "UY")], limit=1)
|
|
return partner or self.env["res.partner"].create({
|
|
"name": xml_tree.findtext(".//{*}RznSoc"),
|
|
"vat": partner_vat_RUC,
|
|
"city": xml_tree.findtext(".//{*}Ciudad"),
|
|
"street": xml_tree.findtext(".//{*}DomFiscal"),
|
|
"state_id": state_id.id if state_id else None,
|
|
"country_id": self.env.ref("base.uy").id,
|
|
"l10n_latam_identification_type_id": self.env.ref("l10n_uy.it_rut").id,
|
|
"is_company": True
|
|
})
|
|
|
|
def _create_pdf_vendor_bill(self, move, req_data_pdf):
|
|
""" DEPRECATED PARAMETER: 'move' is no longer used.
|
|
Will connect to Uruware to get the a legal PDF representation of the EDI doc and attach it to the vendor
|
|
bill. """
|
|
self.ensure_one()
|
|
result = self._ucfe_query('ObtenerPdfCfeRecibido', req_data_pdf)
|
|
errors = result.get('errors')
|
|
if errors := result.get('errors'):
|
|
msg_error = _("It is not possible to create the pdf for this move. Error: %(errors)s.", errors="\n - ".join(errors))
|
|
self.move_id.message_post(body=msg_error)
|
|
return
|
|
|
|
response = result.get("response")
|
|
name = f"{self.move_id.l10n_latam_document_type_id.doc_code_prefix} {req_data_pdf['serieCfe']}{req_data_pdf['numeroCfe'].zfill(7).replace('/', '_')}.pdf"
|
|
return self.env["ir.attachment"].create({
|
|
"name": name,
|
|
"res_model": self.move_id._name,
|
|
"res_field": "invoice_pdf_report_file",
|
|
"res_id": self.move_id.id,
|
|
"type": "binary",
|
|
"datas": response.findtext('.//{*}ObtenerPdfCfeRecibidoResult').encode()
|
|
})
|
|
|
|
def cron_l10n_uy_edi_get_vendor_bills(self, batch_size=10):
|
|
""" UY: Create vendor bills from Uruware. If there are notifications available on Uruware side then here
|
|
is pulled that information, then we create the vendor bill and after that we dismiss the notification to
|
|
continue reading the next one until there are no more notifications available. """
|
|
company_errors = {}
|
|
cron_limit_time = tools.config['limit_time_real_cron'] or -1
|
|
limit_time = cron_limit_time if 0 < cron_limit_time < 300 else 300
|
|
start_time = fields.Datetime.now()
|
|
processed_notifications = 0
|
|
for company in self.env['res.company'].search([]).filtered(lambda x: x.country_code == 'UY'):
|
|
if not self.env['account.journal'].search([
|
|
*self.env['account.tax']._check_company_domain(company),
|
|
('type', '=', 'purchase')
|
|
], limit=1):
|
|
continue
|
|
notifications = True
|
|
while notifications and processed_notifications < batch_size:
|
|
# 600 - Check for available notifications.
|
|
response_600 = self._notification_consult(company)
|
|
if errors := response_600['errors']:
|
|
joined_errors_msg = ". ".join(errors)
|
|
error_msg = _("We found an error while consulting a notification %(joined_errors_msg)s.", joined_errors_msg=joined_errors_msg)
|
|
company_errors[f'{company.id}'] = error_msg
|
|
notifications = False
|
|
continue
|
|
|
|
# Verify response_600 code
|
|
cod_rta_status = self._notification_verify_codrta(company, response_600['response'])
|
|
if not cod_rta_status['response']:
|
|
if error := cod_rta_status['error']:
|
|
company_errors[f'{company.id}'] = error
|
|
notifications = False
|
|
continue
|
|
|
|
# 610 - Request notification details.
|
|
l10n_uy_idreq = response_600['response'].findtext('.//{*}IdReq')
|
|
response_610 = self._ucfe_inbox("610", {"IdReq": l10n_uy_idreq, 'company': company})
|
|
if errors := response_610['errors']:
|
|
company_errors[f'{company.id}'] = "\n".join(errors)
|
|
notifications = False
|
|
continue
|
|
|
|
doc_type = self.env['account.move']._l10n_uy_edi_get_cfe_document_type(response_610['response'])
|
|
# Only implemented for vendor bills and vendor refunds
|
|
if doc_type and doc_type.code not in ['124', '181', '182', '224', '281', '282']:
|
|
move = self._create_edi_move(
|
|
response_610['response'],
|
|
company=company,
|
|
doc_type=doc_type,
|
|
l10n_uy_idreq=l10n_uy_idreq + '-notification'
|
|
)
|
|
xml_cfe_firmado = response_610['response'].findtext(".//{*}XmlCfeFirmado")
|
|
|
|
document_number = response_610['response'].findtext(".//{*}Serie") +\
|
|
(response_610['response'].findtext(".//{*}NumeroCfe") or response_610['response'].findtext(".//{*}Nro")).zfill(7)
|
|
|
|
self.env["ir.attachment"].create({
|
|
"name": f"CFE_{document_number}.xml",
|
|
"res_model": "l10n_uy_edi.document",
|
|
"res_id": move.l10n_uy_edi_document_id.id,
|
|
"res_field": "attachment_file",
|
|
"type": "binary",
|
|
"datas": base64.b64encode(xml_cfe_firmado.encode()
|
|
if xml_cfe_firmado
|
|
else etree.tostring(response_610['response']))})
|
|
move._l10n_uy_edi_complete_cfe_from_xml(etree.fromstring(xml_cfe_firmado))
|
|
else:
|
|
# Until now we are not supporting the creation of e-Resguardos and e-Remitos
|
|
doc_type_name = doc_type.name if doc_type else 'undefined'
|
|
company_errors[f'{company.id}'] = _("Up to now it is not possible to create %(doc_type_name)s documents. IdReq: %(l10n_uy_idreq)s",
|
|
doc_type_name=doc_type_name,
|
|
l10n_uy_idreq=l10n_uy_idreq + '-notification')
|
|
response_620 = self._notification_dismiss(company, response_600['response'])
|
|
if response_620['response'].findtext('.//{*}CodRta') != "00":
|
|
company_errors[f'{company.id}'] = etree.tostring(response_620['response'])
|
|
notifications = False
|
|
continue
|
|
if errors := response_620['errors']:
|
|
company_errors[f'{company.id}'] = str(errors)
|
|
notifications = False
|
|
continue
|
|
processed_notifications += 1
|
|
self._verify_company_errors_cron_l10n_uy_edi_get_vendor_bills(company_errors)
|
|
if (fields.Datetime.now().timestamp() - start_time.timestamp() > limit_time) or notifications:
|
|
self.env.ref('l10n_uy_edi.ir_cron_get_vendor_bills_received')._trigger()
|
|
|
|
def _verify_company_errors_cron_l10n_uy_edi_get_vendor_bills(self, company_errors={}):
|
|
if company_errors:
|
|
_logger.warning(_('An error was found when synchronizing vendor bills\n'))
|
|
for key, value in company_errors.items():
|
|
_logger.warning(_('Company Name: "%(company_name)s", Company ID: (%(company_id)s), Errors: "%(error)s"',
|
|
company_name=self.env['res.company'].browse(int(key)).name, company_id=key, error=value)
|
|
)
|
|
|
|
@api.model
|
|
def _create_edi_move(self, file_data, move=None, company=None, doc_type=None, l10n_uy_idreq=None):
|
|
"""Create move if does not exists and create edi document. 'uuid' edi document field containts the suffix
|
|
'-manual' if the move was created by uploading the xml manually or the suffix '-notification' if the move was
|
|
created by the cron 'UY: Create vendor bills (sync from Uruware)'."""
|
|
company = company or self.env.company
|
|
values = {
|
|
'company_id': company.id,
|
|
'move_type': doc_type._l10n_uy_edi_get_move_type(),
|
|
'journal_id': self.env["account.journal"]
|
|
.search(
|
|
[
|
|
*self.env["account.journal"]._check_company_domain(company),
|
|
("type", "=", "purchase"),
|
|
("currency_id", "=", False),
|
|
],
|
|
limit=1,
|
|
)
|
|
.id,
|
|
}
|
|
if move:
|
|
move.write(values)
|
|
else:
|
|
move = self.env["account.move"].create(values)
|
|
edi_doc = self.env["l10n_uy_edi.document"].create(
|
|
{
|
|
'move_id': move.id,
|
|
'uuid': l10n_uy_idreq,
|
|
}
|
|
)
|
|
move.l10n_uy_edi_document_id = edi_doc
|
|
return move
|
|
|
|
def _notification_consult(self, company=False):
|
|
""" 600 - Consult notifications available on Uruware. """
|
|
return self._ucfe_inbox("600", {"TipoNotificacion": "7", "company": company})
|
|
|
|
def _notification_dismiss(self, company, response):
|
|
""" This is implemented for vendor bills. It is needed to dismiss the last notification if the last vendor bill was
|
|
created in Odoo from Uruware. To dismiss the last notification is needed to use the operation "620 - Descartar
|
|
una notificación" with IdReq and TipoNotificacion. If is not possible to dismiss the last notification it will
|
|
be returned the code "00" """
|
|
id_req = response.findtext('.//{*}IdReq')
|
|
response_620 = self._ucfe_inbox("620", {
|
|
"IdReq": id_req,
|
|
"TipoNotificacion": response.findtext('.//{*}TipoNotificacion'),
|
|
"company": company})
|
|
return response_620
|
|
|
|
def _notification_verify_codrta(self, company, response_600):
|
|
""" DEPRECATED PARAMETER: 'company' is no longer used.
|
|
Verify response code from notifications (vendor bills). If response code is != 0 return False (can`t create
|
|
new vendor bill), else return True (continue the process and create vendor bill).
|
|
Available values for response code:
|
|
00 Petición aceptada y procesada.
|
|
01 Petición denegada.
|
|
03 Comercio inválido.
|
|
12 Requerimiento inválido.
|
|
30 Error en formato.
|
|
31 Error en formato de CFE.
|
|
89 Terminal inválida.
|
|
96 Error en sistema.
|
|
99 Sesión no iniciada.
|
|
? Any other no specified code must be understanded as
|
|
denied requirement."""
|
|
cod_rta = response_600.findtext('.//{*}CodRta')
|
|
response = {'response': False, 'error': ''}
|
|
if cod_rta == "01":
|
|
return response
|
|
elif cod_rta != "00":
|
|
response_msg = _("ERROR: This is what we receive when requesting notification data (610) %(tree)s", tree=etree.tostring(response_600))
|
|
response['error'] = response_msg
|
|
return response
|
|
response['response'] = True
|
|
return response
|