forked from Mapan/odoo17e
440 lines
18 KiB
Python
440 lines
18 KiB
Python
import logging
|
|
from lxml import etree
|
|
import re
|
|
from requests.exceptions import Timeout, ConnectionError, HTTPError
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
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 L10nUyEdiDocument(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(related="move_id.l10n_latam_document_type_id")
|
|
l10n_latam_document_number = fields.Char(related="move_id.l10n_latam_document_number")
|
|
company_id = fields.Many2one(related="move_id.company_id")
|
|
partner_id = fields.Many2one(related="move_id.partner_id")
|
|
|
|
# Compute methods
|
|
|
|
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:
|
|
result = edi_doc._ucfe_inbox("360", {"Uuid": edi_doc.uuid})
|
|
edi_doc._update_cfe_state(result)
|
|
|
|
# Extended methods
|
|
|
|
def unlink(self):
|
|
self.attachment_id.unlink()
|
|
return super().unlink()
|
|
|
|
# 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
|
|
|
|
@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] """
|
|
return re.findall(r"([A-Z])[-]*([0-9]*)", record.l10n_latam_document_number)[-1]
|
|
|
|
@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:
|
|
res.append("{ %s }" % addenda.content if addenda.is_legend else addenda.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(".//{*}ObtenerPdfResult").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, extra params not needed.
|
|
This has been implemented in a separate method to be inheritable for some
|
|
partner and customer custom reports """
|
|
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 """
|
|
if company.l10n_uy_edi_ucfe_env == "demo":
|
|
return False
|
|
|
|
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(_("Uruware Inbox URL"))
|
|
query_url = self._get_ws_url("query", company)
|
|
if not query_url:
|
|
missing_info.append(_("Uruware Query URL"))
|
|
username = self._get_ucfe_username(company)
|
|
if not username:
|
|
missing_info.append(_("Uruware Username"))
|
|
|
|
if missing_info:
|
|
return _(
|
|
"Incomplete Data to connect to Uruware 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 = self.company_id or self.env.company
|
|
data = {
|
|
"CodComercio": company.l10n_uy_edi_ucfe_commerce_code,
|
|
"CodTerminal": company.l10n_uy_edi_ucfe_terminal_code,
|
|
"RequestDate": now.replace(microsecond=0).isoformat(),
|
|
"Tout": "30000",
|
|
"Req": {
|
|
"TipoMensaje": msg_type,
|
|
"CodComercio": company.l10n_uy_edi_ucfe_commerce_code,
|
|
"CodTerminal": company.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 = []
|
|
url = self._get_ws_url(endpoint, company)
|
|
|
|
if 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
|