Files
enterprise-addons/l10n_uy_edi_stock/models/stock_picking.py
T

539 lines
23 KiB
Python

import base64
import logging
from lxml import etree
from markupsafe import Markup
from odoo.addons.l10n_uy_edi.models.account_move import format_float
from odoo.exceptions import UserError
from odoo.tools import html2plaintext
from odoo.tools.xml_utils import cleanup_xml_node
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class StockPicking(models.Model):
_inherit = "stock.picking"
_description = "Stock Picking - Delivery Guide (Uruguay)"
l10n_latam_document_type_id = fields.Many2one(
comodel_name="l10n_latam.document.type",
string="Document Type (UY)",
compute="_compute_l10n_latam_document_type_id",
readonly=False,
store=True,
)
l10n_latam_document_number = fields.Char(string="Document Number (UY)", readonly=True, copy=False)
l10n_latam_available_document_type_ids = fields.Many2many(
"l10n_latam.document.type", compute="_compute_l10n_latam_available_document_types"
)
l10n_uy_edi_document_id = fields.Many2one("l10n_uy_edi.document", string="Uruguay E-Remito CFE", copy=False)
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_is_cfe = fields.Boolean(
compute="_compute_l10n_uy_is_cfe",
help="Technical field to know if it's an electronic document or not and use it in the view to show or require certain fields.",
)
l10n_uy_edi_addenda_ids = fields.Many2many(
"l10n_uy_edi.addenda",
string="Addenda & Disclosure",
domain="[('type', '=', 'addenda'), ('company_id', 'in', [company_id, False])]",
help="Addendas and Mandatory Disclosure to add on the CFE. They can be added either to the issuer, receiver,"
" cfe doc additional info 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_reference = fields.Many2one(
"l10n_uy_edi.document",
string="EDI Reference",
domain="[('picking_id', '!=', False), ('picking_id.partner_id', '=', partner_id), ('state', 'in', ['accepted', 'received', 'rejected'])]",
help="If filled with a reference, means the Delivery Guide is a correction of the selected document. "
"Only documents from the same partner can be referenced.",
ondelete="set null",
)
l10n_uy_edi_operation_type = fields.Selection(
[("1", "Sale"), ("2", "Internal Transfer")],
string="Type of Operation (UY)",
readonly=False,
store=True,
copy=False,
)
l10n_uy_edi_pdf_report_id = fields.Many2one(
comodel_name="ir.attachment",
string="PDF Attachment",
compute=lambda self: self._compute_linked_attachment_id("l10n_uy_edi_pdf_report_id", "l10n_uy_edi_pdf_report_file"),
depends=["l10n_uy_edi_pdf_report_file"],
)
l10n_uy_edi_pdf_report_file = fields.Binary(
attachment=True,
string="PDF File",
copy=False,
)
# Compute methods
@api.depends("l10n_uy_edi_operation_type", "l10n_latam_available_document_type_ids")
def _compute_l10n_latam_document_type_id(self):
for picking in self:
if picking.l10n_uy_edi_operation_type:
picking.l10n_latam_document_type_id = picking.l10n_latam_available_document_type_ids.filtered(
lambda x: x.code == "181"
) # e-Delivery Guide Document
else:
picking.l10n_latam_document_type_id = False
@api.depends("l10n_latam_document_number")
def _compute_display_name(self):
"""Display: 'Stock Picking Internal Sequence : Delivery Guide Number (if defined)'"""
super()._compute_display_name()
for picking in self.filtered(lambda x: x.l10n_latam_document_number):
picking.display_name = picking.name + ": (%s %s)" % (
picking.l10n_latam_document_type_id.doc_code_prefix,
picking.l10n_latam_document_number,
)
@api.depends("country_code", "picking_type_code", "l10n_latam_document_type_id")
def _compute_l10n_uy_is_cfe(self):
for picking in self:
picking.l10n_uy_is_cfe = (
picking.country_code == "UY"
and picking.picking_type_code == "outgoing"
and picking.l10n_latam_document_type_id.code == "181"
)
@api.depends("partner_id", "company_id", "picking_type_code")
def _compute_l10n_latam_available_document_types(self):
uy_pickings = self.filtered(lambda x: x.country_code == "UY" and x.picking_type_code == "outgoing")
uy_pickings.l10n_latam_available_document_type_ids = self.env["l10n_latam.document.type"].search(
self._get_l10n_latam_documents_domain()
)
(self - uy_pickings).l10n_latam_available_document_type_ids = False
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)]
)
move_vals = {att.res_id: att for att in attachments}
for move in self:
move[attachment_field] = move_vals.get(move._origin.id, False)
# Buttons
def action_cancel(self):
"""The delivery guide can not be modified once it has been accepted by DGI"""
if self.filtered(
lambda x: x.l10n_uy_is_cfe and x.l10n_uy_edi_cfe_state in ["accepted", "rejected", "received"]
):
raise UserError(self.env._("You can not cancel a Delivery Guide that has already been processed by DGI"))
return super().action_cancel()
def l10n_uy_edi_action_update_dgi_state(self):
"""Action to update the DGI status of the picking, it will post a message in the chatter if the picking
has been rejected by DGI"""
self.l10n_uy_edi_document_id.action_update_dgi_state()
rejected_pickings = self.filtered(lambda x: x.l10n_uy_edi_cfe_state == "rejected")
if rejected_pickings:
_logger.info(
"Rejected picking(s): %s",
[(rec.id, rec.name) for rec in rejected_pickings],
)
for picking in rejected_pickings:
picking.message_post(
body=self.env._(
"The CFE has been rejected by DGI. Please, manually check the reject reason "
"and generate/send a new CFE with the fixes"
),
message_type="comment",
subtype_xmlid="mail.mt_note",
)
def l10n_uy_edi_create_delivery_guide(self):
""" Create the e-Remito (Delivery Guide) CFE and send it to DGI.
The e-Remito has the following parts in the XML file:
A. Encabezado
B. Detalle de los productos
C. Subtotales Informativos (optional)
F. Informacion de Referencia (conditional)
"""
# Filter only e-remitos
pickings = self.filtered(
lambda x: x.country_code == "UY"
and x.picking_type_code == "outgoing"
and x.l10n_latam_document_type_id
and int(x.l10n_latam_document_type_id.code) > 0
and x.l10n_uy_edi_cfe_state not in ["accepted", "rejected", "received"]
)
if not pickings:
return
# Send invoices to DGI and get the return info
msg = ""
self.env['res.company']._with_locked_records(pickings)
for picking in pickings:
edi_doc = self.env["l10n_uy_edi.document"].create(
{
"picking_id": picking.id,
"uuid": self.env["l10n_uy_edi.document"]._get_picking_uuid(picking),
}
)
picking.l10n_uy_edi_document_id = edi_doc
if picking.company_id.l10n_uy_edi_ucfe_env == "demo":
attachments = picking._l10n_uy_edi_dummy_validation()
msg = self.env._(
"This CFE has been generated in DEMO Mode. It is considered"
' as accepted and it won"t be sent to DGI.'
)
else:
request_data = picking._l10n_uy_stock_prepare_req_data()
result = edi_doc._send_dgi(request_data)
edi_doc._update_cfe_state(result)
response = result.get("response")
if edi_doc.message:
picking.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, save the return information
picking.l10n_latam_document_number = response.findtext(".//{*}Serie") + "%07d" % int(
response.findtext(".//{*}NumeroCfe")
)
msg = response.findtext(".//{*}MensajeRta", "")
msg += self.env._("The electronic invoice was created successfully")
if response is not None:
attachments = picking._l10n_uy_edi_update_xml_and_pdf_file(response)
else:
attachments = None
picking.message_post(
body=msg,
attachment_ids=attachments.ids if attachments else False,
)
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()
# Helpers
def _get_l10n_latam_documents_domain(self):
codes = self._l10n_uy_get_delivery_guide_codes()
return [
("code", "in", codes),
("code", "!=", "0"),
("active", "=", True),
("internal_type", "=", "stock_picking"),
]
def _l10n_uy_edi_update_xml_and_pdf_file(self, response):
"""Cleans up the PDF and XML fields. Creates new ones with the response"""
self.ensure_one()
res_files = self.env["ir.attachment"]
edi_doc = self.l10n_uy_edi_document_id
edi_doc._compute_from_origin()
self.l10n_uy_edi_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=["l10n_uy_edi_pdf_report_id", "l10n_uy_edi_pdf_report_file"])
res_files |= pdf_file
if errors := pdf_result.get("errors"):
msg = self.env._("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 _l10n_uy_edi_dummy_validation(self):
# Extends l10n_uy_edi
""" When we want to skip DGI and validate only in Odoo.
Change move_id with picking_id"""
edi_doc = self.l10n_uy_edi_document_id
edi_doc.state = "accepted"
self.write(
{
"l10n_latam_document_number": "DE%07d" % (edi_doc.picking_id.id),
}
)
return self._l10n_uy_edi_get_preview_xml()
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_pdf(self):
"""Calls endpoint to get PDF file from Uruware (Standard Representation)
return: dictionary with {"errors": str(): "pdf_file"attachment object }"""
res = {}
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": "stock.picking",
"res_id": self.id,
"res_field": "l10n_uy_edi_pdf_report_file",
"name": self.l10n_uy_edi_document_id._get_xml_attachment_name().replace(".xml", ".pdf"),
"type": "binary",
"datas": file_content,
}
)
res["pdf_file"] = pdf_file
return res
def _l10n_uy_edi_get_addenda(self):
"""Returns a string with the addenda of the e-Rem"""
addenda = ""
if self.l10n_uy_edi_reference:
addenda = f"Correction of {self.l10n_uy_edi_reference.picking_id.name}"
addenda += self.l10n_uy_edi_document_id._get_legends("addenda", self)
if self.origin:
addenda += "\n\nOrigin: %s" % self.origin
if self.note:
addenda += "\n\n%s" % html2plaintext(self.note)
return addenda.strip()
def _l10n_uy_get_delivery_guide_codes(self):
"""Returns a list of the available document type codes for stock picking"""
return ["0", "181"]
# Prepare XML values
def _l10n_uy_stock_prepare_req_data(self):
"""Creates a dictionary with the request to generate the 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_get_cfe_lines(self):
self.ensure_one()
return self.move_line_ids
def _l10n_uy_edi_get_xml_content(self):
"""Creates the CFE xml structure and validate it
Returns a string with the xml content to send to DGI"""
self.ensure_one()
template_name = "l10n_uy_edi_stock." + self.l10n_uy_edi_document_id._get_cfe_picking_tag(self) + "_template"
values = {
"cfe": self,
"res_model": self._name,
"IdDoc": self._l10n_uy_stock_cfe_A_iddoc(),
"emisor": self._l10n_uy_stock_cfe_A_issuer(),
"receptor": self._l10n_uy_stock_cfe_A_receptor(),
"totals_detail": self._l10n_uy_stock_cfe_A_totals(),
"item_detail": self._l10n_uy_stock_cfe_B_details(),
"referencia_lines": self._l10n_uy_edi_cfe_F_reference(),
"format_float": format_float,
}
cfe = self.env["ir.qweb"]._render(template_name, values=values)
return etree.tostring(cleanup_xml_node(cfe)).decode()
def _l10n_uy_stock_cfe_A_iddoc(self):
"""Prepares XML Section A (Encabezado / Identificacion del Documento)"""
values = {
"TipoCFE": self.l10n_latam_document_type_id.code,
"FchEmis": self.scheduled_date.date(),
"TipoTraslado": self.l10n_uy_edi_operation_type, # A5
}
empty_values = {}.fromkeys(
["MntBruto", "FmaPago", "FchVenc", "ClauVenta", "InfoAdicionalDoc", "ModVenta", "ViaTransp"], None
)
values.update(empty_values)
return values
def _l10n_uy_stock_cfe_A_issuer(self):
"""Prepares XML Section A (Encabezado / Issuer)"""
return {
"RUCEmisor": self.company_id.vat,
"RznSoc": self.company_id.name[:150],
"CdgDGISucur": self.company_id.l10n_uy_edi_branch_code,
"DomFiscal": self.company_id.partner_id._l10n_uy_edi_get_fiscal_address(),
"Ciudad": (self.company_id.city or "")[:30] or None,
"Departamento": (self.company_id.state_id.name or "")[:30] or None,
"InfoAdicionalEmisor": self.l10n_uy_edi_document_id._get_legends("issuer", self) or None,
}
def _l10n_uy_stock_cfe_A_receptor(self):
"""Prepares XML Section A (Encabezado / Receptor)"""
self.ensure_one()
doc_type = self.partner_id._l10n_uy_edi_get_doc_type()
values = {
"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.partner_id.vat if doc_type in [1, 2, 3] else None, # A62
"DocRecepExt": self.partner_id.vat if doc_type not in [1, 2, 3] else None, # A62.1
"RznSocRecep": self.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
"LugarDestEnt": self._l10n_uy_edi_get_delivery_address() or None, # A69
}
empty_values = {}.fromkeys(["CompraID"], None)
values.update(empty_values)
return values
def _l10n_uy_edi_get_delivery_address(self):
"""Gets the delivery address of the picking, if it exists"""
self.ensure_one()
if self.partner_id.type == "delivery":
return self.partner_id.contact_address_complete
def _l10n_uy_stock_cfe_A_totals(self):
"""Prepares XML Section C (Subtotales Informativos)"""
self.ensure_one()
lines = self._l10n_uy_edi_get_cfe_lines()
res = {
"CantLinDet": len(lines), # A126
}
empty_values = {}.fromkeys(
[
"MntNoGrv",
"MntNetoIvaTasaMin",
"MntNetoIVATasaBasica",
"MntNetoIVAOtra",
"IVATasaMin",
"IVATasaBasica",
"MntIVATasaMin",
"MntIVATasaBasica",
"MntIVAOtra",
"MntTotal",
"MontoNF",
"MntPagar",
"TpoMoneda",
"TpoCambio",
"MntExpoyAsim",
],
None,
)
res.update(empty_values)
return res
def _l10n_uy_stock_cfe_B_details(self):
"""Prepares XML Section B (Detalles)"""
self.ensure_one()
res = []
for k, line in enumerate(self.move_line_ids, start=1):
temp = {
"NroLinDet": k, # B1
"IndFact": 8 if self.l10n_uy_edi_reference else None, # B4
"NomItem": line.display_name, # B7
"DscItem": line.description_picking
if line.description_picking and line.description_picking != line.display_name
else None, # B8
"Cantidad": line.quantity, # B9
"UniMed": line.product_uom_id.name[:4] if line.product_uom_id else "N/A", # B10
}
empty_values = {}.fromkeys(
[
"PrecioUnitario",
"DescuentoPct",
"DescuentoMonto",
"MontoItem",
],
None,
)
temp.update(empty_values)
res.append(temp)
return res
def _l10n_uy_edi_cfe_F_reference(self):
"""Prepares XML Section F (Referencias)"""
self.ensure_one()
res = []
if self.l10n_uy_edi_reference:
referenced_doc = self.l10n_uy_edi_reference
cfe_serie, cfe_number = self.l10n_uy_edi_document_id._get_doc_parts(referenced_doc)
res.append(
{
"NroLinRef": 1, # F1
"TpoDocRef": int(referenced_doc.l10n_latam_document_type_id.code), # F3
"Serie": cfe_serie, # F4
"NroCFERef": cfe_number, # F5
}
)
return res
def _l10n_uy_edi_stock_cron_update_dgi_state(self, batch_size=10):
"""Cron to update the DGI state of the stock pickings"""
domain = [("l10n_uy_edi_cfe_state", "=", "received")]
pickings = self.env["stock.picking"].search(domain, limit=batch_size + 1, order="id")
need_retrigger = len(pickings) > batch_size
pickings = pickings[:batch_size]
pickings.l10n_uy_edi_action_update_dgi_state()
if need_retrigger:
self.env.ref("l10n_uy_edi_stock.ir_cron_update_dgi_state_pickings")._trigger()