se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-02 07:40:26 -05:00
1 parent fcc80d3c6e
commit e6cde62e72
14 files changed
+1711 -237

No files matched your search

+217 -16
View File
@@ -11,11 +11,39 @@ import xml.etree.ElementTree as ET
from dataclasses import dataclass, asdict, field
from typing import Optional, List
# ============================================================================
# Patrones regex organizados por tipo de extracción
# ============================================================================
# Serie-Número: busca cualquier patrón T001-123456, V001-789, etc.
SERIE_NUMERO_PATTERNS = [
r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b',
]
# Guías desde Notes de InvoiceLine: cada entrada es (patron, tipo_documento)
GUIAS_NOTES_PATTERNS = [
# GRE: "GRE REMITENTE: T001-128912", "GRR.T001-129352", "G.R.R:T001-132131"
(r"(?:GRE\s+REMITENTE|GRR|G\.R\.R)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-\s*(\d+)", "09"),
# GRT: "GRT:V001-194", "GRT.V001-227", "G.R.T: EG03-4643"
(r"\b(?:GRT|G\.R\.T)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-\s*(\d+)", "31"),
]
# Valor referencial desde la descripción del Item: cada cliente lo redacta distinto,
# por lo que se prueba una lista de patrones en orden hasta encontrar el primero que calce.
# Agregar aquí nuevas variantes a medida que aparezcan en facturas de otros clientes.
VALOR_REFERENCIAL_PATTERNS = [
r"VALOR\s+REFERENCIAL\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VALOR REFERENCIAL: S/ 2,345.00" / "VALOR REFERENCIAL: 468.86"
r"VALOR\s+REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VALOR REF:S/ 1,139.75"
r"VAL\.?\s*REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VAL.REF.:S/700.25"
r"V\.?\s*REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "V.REF: S/ 2940.80 TN: 36.85"
r"\bV\.\s*R\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "V.R. S/964.51" / "V.R.: 1045.20"
r"\bVR\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VR:S/300.10"
]
# ============================================================================
# Namespaces UBL 2.1 utilizados en los XML de SUNAT
NAMESPACES = {
"inv": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"desp": "urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2",
"ar": "urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2",
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
"ext": "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
@@ -47,6 +75,10 @@ class DetalleLinea:
cantidad: float
precio_unitario: float
total_linea: float = 0.0
notes_text: str = ""
guias_remision: List[dict] = field(default_factory=list)
guia_transportista: Optional[dict] = None
valor_referencial: Optional[float] = None
@dataclass
@@ -123,6 +155,26 @@ class DatosComprobante:
# ============================================================================
def _inferir_tipo_desde_serie(serie: str) -> str:
"""
Infiere el tipo de documento SUNAT desde el prefijo de la serie.
- Serie que empieza con 'T' → tipo 09 (Guía de Remisión Remitente)
- Serie que empieza con 'V' o 'E' → tipo 31 (Guía de Remisión Transportista)
- Otros → tipo 09 por defecto
"""
if serie and serie[0] == "T":
return "09"
if serie and serie[0] in ("V", "E"):
return "31"
return "09"
def _es_referencia_valida(serie: str, numero: str) -> bool:
"""Valida que una referencia tenga serie no vacía y número > 0."""
return bool(serie) and numero not in ("", "0")
def _find_text(element: ET.Element, xpath: str, namespaces: dict) -> Optional[str]:
"""Busca un elemento por XPath y devuelve su texto, o None si no existe."""
node = element.find(xpath, namespaces)
@@ -131,39 +183,103 @@ def _find_text(element: ET.Element, xpath: str, namespaces: dict) -> Optional[st
return None
# ============================================================================
def split_serie_numero(comprobante_id: str) -> tuple[str, str]:
"""Public alias for _split_serie_numero."""
return _split_serie_numero(comprobante_id)
def _split_serie_numero(comprobante_id: str) -> tuple[str, str]:
"""
Separa el ID del comprobante en serie y número, removiendo ceros a la izquierda del número.
Ejemplo: 'F002-3091' -> ('F002', '3091')
'T001-00128143' -> ('T001', '128143')
'EG03-1752' -> ('EG03', '1752')
'T001 - 00130229' -> ('T001', '130229')
'0002 - 000608' -> ('0002', '608')
"""
if "-" in comprobante_id:
partes = comprobante_id.split("-", 1)
# Remueve ceros a la izquierda, si queda vacío (ej: '000') se mantiene '0'
numero = partes[1].lstrip("0") or "0"
return partes[0], numero
return comprobante_id, ""
serie = partes[0].strip()
# Remueve espacios y ceros a la izquierda, si queda vacío (ej: '000') se mantiene '0'
numero = partes[1].strip().lstrip("0") or "0"
return serie, numero
return comprobante_id.strip(), ""
# ============================================================================
def _extraer_serie_numero_desde_texto(texto: str) -> tuple[str, str] | None:
texto = re.sub(r'[–—−]', '-', texto)
patron = r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b'
m = re.search(patron, texto)
if m:
return m.group(1).upper(), m.group(2)
for patron in SERIE_NUMERO_PATTERNS:
m = re.search(patron, texto)
if m:
numero = m.group(2).lstrip("0") or "0"
return m.group(1).upper(), numero
return None
def _numeros_extra_guia(texto: str, pos: int) -> list[str]:
"""
Desde una posición devuelve números adicionales que comparten la misma serie
de una guía ya capturada, separados por `,`, `/`, `\\` o `;`.
Ejemplo: ``"T001-133023,133024"`` con ``pos`` tras ``133023`` → ``["133024"]``
"""
numeros = []
resto = texto[pos:]
while True:
m = re.match(r'\s*[,;\\/]\s*(\d+)', resto)
if not m:
break
numeros.append(m.group(1).lstrip("0") or "0")
resto = resto[m.end():]
return numeros
def extraer_guias_desde_notes(notes_text: str) -> list[dict]:
"""
Extrae guías de remisión (GRE/GRT) desde el texto de Notes de una InvoiceLine.
Retorna lista de dicts con ``tipo``, ``serie``, ``numero``.
"""
resultados = []
for patron, tipo in GUIAS_NOTES_PATTERNS:
for m in re.finditer(patron, notes_text, re.IGNORECASE):
serie = m.group(1).upper()
numero = m.group(2).lstrip("0") or "0"
resultados.append({"tipo": tipo, "serie": serie, "numero": numero})
for extra in _numeros_extra_guia(notes_text, m.end()):
resultados.append({"tipo": tipo, "serie": serie, "numero": extra})
return resultados
def extraer_valor_referencial(texto: str) -> Optional[float]:
"""
Extrae el valor referencial (importe) desde el texto de descripción de un Item.
Prueba cada patrón de ``VALOR_REFERENCIAL_PATTERNS`` en orden y devuelve el
primer importe encontrado.
Ejemplos: ``"...VALOR REF:S/ 1,139.75"`` -> ``1139.75``
``"...V.R. S/964.51"`` -> ``964.51``
"""
for patron in VALOR_REFERENCIAL_PATTERNS:
m = re.search(patron, texto, re.IGNORECASE)
if m:
return float(m.group(1).replace(",", ""))
return None
def obtener_documentos_referencia_detalle_factura(texto: str) -> list[str]:
texto = re.sub(r'[–—−]', '-', texto)
patron = r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b'
return list(dict.fromkeys(
f"{serie.upper()}-{numero}"
for serie, numero in re.findall(patron, texto, flags=re.IGNORECASE)
))
resultados = []
for patron in SERIE_NUMERO_PATTERNS:
for m in re.finditer(patron, texto, flags=re.IGNORECASE):
serie = m.group(1).upper()
numero = m.group(2)
resultados.append(f"{serie}-{numero}")
for extra in _numeros_extra_guia(texto, m.end()):
resultados.append(f"{serie}-{extra}")
return list(dict.fromkeys(resultados))
# ============================================================================
def _parse_invoice(root: ET.Element) -> DatosComprobante:
"""Extrae datos de un documento Invoice (Factura/Boleta)."""
@@ -228,7 +344,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
ref_serie, ref_numero = parsed
else:
ref_serie, ref_numero = _split_serie_numero(ref_id)
documentos_referencia.append(DocumentoReferencia(tipo=ref_tipo, serie=ref_serie, numero=ref_numero))
if _es_referencia_valida(ref_serie, ref_numero):
tipo_ref = ref_tipo if ref_tipo else _inferir_tipo_desde_serie(ref_serie)
documentos_referencia.append(DocumentoReferencia(tipo=tipo_ref, serie=ref_serie, numero=ref_numero))
# si los documentos de referencia non estan en los tags de SUNAT
# buscamos estos documentos en el cuerpo de la factura
@@ -242,7 +360,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
guias = obtener_documentos_referencia_detalle_factura(description.text)
if guias:
for guia in guias:
documentos_referencia.append(DocumentoReferencia(tipo="09" if guia[:1] in ("T", "V", "E") else "31", serie=_split_serie_numero(guia)[0], numero=_split_serie_numero(guia)[1]))
serie_parsed, numero_parsed = _split_serie_numero(guia)
if _es_referencia_valida(serie_parsed, numero_parsed):
documentos_referencia.append(DocumentoReferencia(tipo=_inferir_tipo_desde_serie(serie_parsed), serie=serie_parsed, numero=numero_parsed))
# Inicializar campos de detracción y líneas
tiene_detraccion = False
@@ -253,6 +373,12 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
# Solo si el tipo es Factura (01) se extraen detracciones y líneas detalladas
if tipo == "01":
# Notas a nivel de documento (cbc:Note en la raíz): algunos clientes ponen
# el valor referencial aquí en vez de en la descripción del Item, ej:
# "V.REF: S/ 2940.80 TN: 36.85"
root_notes = root.findall("cbc:Note", ns)
root_notes_text = " | ".join(n.text.strip() for n in root_notes if n.text) if root_notes else ""
# Buscar detracción en PaymentTerms
for pt_node in root.findall("cac:PaymentTerms", ns):
pt_id = _find_text(pt_node, "cbc:ID", ns)
@@ -271,6 +397,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
for line_node in root.findall("cac:InvoiceLine", ns):
descripcion = _find_text(line_node, "cac:Item/cbc:Description", ns) or ""
notes = line_node.findall("cbc:Note", ns)
notes_text = " | ".join(n.text.strip() for n in notes if n.text) if notes else ""
qty_str = _find_text(line_node, "cbc:InvoicedQuantity", ns)
cantidad = float(qty_str) if qty_str else 0.0
@@ -284,12 +413,26 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
total_linea_str = _find_text(line_node, "cbc:LineExtensionAmount", ns)
total_linea = float(total_linea_str) if total_linea_str else 0.0
guias = extraer_guias_desde_notes(notes_text)
guias_remision = [g for g in guias if g["tipo"] == "09"]
gt = [g for g in guias if g["tipo"] == "31"]
valor_referencial = (
extraer_valor_referencial(descripcion)
or extraer_valor_referencial(notes_text)
or extraer_valor_referencial(root_notes_text)
)
lineas.append(
DetalleLinea(
descripcion=descripcion,
cantidad=cantidad,
precio_unitario=precio_unitario,
total_linea=total_linea,
notes_text=notes_text,
guias_remision=guias_remision,
guia_transportista=gt[0] if gt else None,
valor_referencial=valor_referencial,
)
)
@@ -455,6 +598,62 @@ def _parse_despatch_advice(root: ET.Element) -> DatosComprobante:
)
# ============================================================================
def _parse_application_response(root: ET.Element) -> dict:
"""Extrae datos de un ApplicationResponse (CDR de SUNAT)."""
ns = NAMESPACES
ruc_emisor = ""
tipo = ""
serie = ""
numero = ""
# RUC del emisor original desde ReceiverParty
ruc_emisor = (
_find_text(root, "cac:ReceiverParty/cac:PartyIdentification/cbc:ID", ns)
or ""
)
# Tipo, serie y número desde DocumentResponse/DocumentReference
for doc_resp in root.findall(".//cac:DocumentResponse", ns):
# DocumentTypeCode del documento original
if not tipo:
tipo = (
_find_text(doc_resp, "cac:DocumentReference/cbc:DocumentTypeCode", ns)
or ""
)
# ID del documento original (ej: FF01-3308, F002-20851)
ref_id = (
_find_text(doc_resp, "cac:DocumentReference/cbc:ID", ns)
or ""
)
if ref_id:
serie, numero = _split_serie_numero(ref_id)
# Si no hay tipo, intentar inferir desde el texto de la descripción
if not tipo:
desc = _find_text(doc_resp, "cac:Response/cbc:Description", ns) or ""
m = re.search(r"\b(?:FACTURA|Factura)\b", desc)
if m:
tipo = "01"
# Si no hay RUC, intentar extraer desde la descripción
if not ruc_emisor:
desc = _find_text(doc_resp, "cac:Response/cbc:Description", ns) or ""
m = re.search(r"\b(\d{11})-\d{2}-", desc)
if m:
ruc_emisor = m.group(1)
return {
"ruc_emisor": ruc_emisor,
"tipo": tipo,
"serie": serie,
"numero": numero,
"es_cdr": True,
"tipo_descripcion": "CDR",
}
def extraer_datos_xml(ruta_xml: str) -> Optional[dict]:
"""
Recibe la ruta de un archivo XML de comprobante electrónico SUNAT.
@@ -478,6 +677,8 @@ def extraer_datos_xml(ruta_xml: str) -> Optional[dict]:
tag = root.tag
if "ApplicationResponse" in tag:
return _parse_application_response(root)
if "Invoice" in tag:
tipo = _find_text(root, "inv:InvoiceTypeCode", NAMESPACES) or _find_text(root, "cbc:InvoiceTypeCode", NAMESPACES) or ""
if tipo == "01":