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:
1 parent
fcc80d3c6e
commit
e6cde62e72
14 files changed
+1711
-237
No files matched your search
+217
-16
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user