1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_uy_edi/models/l10n_uy_edi_document.py
2024-12-10 09:04:09 +07:00

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