2026-10-22 08:55:54 -05:00
|
|
|
"""
|
|
|
|
|
Catálogo de observaciones: los motivos por los que se rechaza un comprobante.
|
|
|
|
|
|
|
|
|
|
Cada observación se muestra al proveedor en la plantilla de rechazo con un
|
|
|
|
|
título, unas filas de detalle y un texto de "cómo corregir". **Los textos de
|
|
|
|
|
este archivo son un borrador** para revisar y ajustar; no hace falta tocar la
|
|
|
|
|
lógica para cambiarlos.
|
|
|
|
|
|
|
|
|
|
``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en
|
|
|
|
|
observaciones; el resto son los motivos de un correo sin comprobante válido.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Observacion:
|
|
|
|
|
codigo: str # identificador estable (se guarda en la BD)
|
|
|
|
|
titulo: str
|
|
|
|
|
filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle
|
|
|
|
|
como_corregir: str = ""
|
|
|
|
|
|
|
|
|
|
def a_dict(self) -> dict:
|
|
|
|
|
"""Forma serializable: se guarda en ``comprobantes.observaciones``."""
|
|
|
|
|
return {
|
|
|
|
|
"codigo": self.codigo,
|
|
|
|
|
"titulo": self.titulo,
|
|
|
|
|
"filas": [{"etiqueta": e, "valor": v} for e, v in self.filas],
|
|
|
|
|
"como_corregir": self.como_corregir,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Formato
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def moneda(valor: float | int | None) -> str:
|
|
|
|
|
"""``1250`` → ``'S/ 1,250.00'``."""
|
|
|
|
|
return f"S/ {float(valor or 0):,.2f}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def texto_observaciones(cantidad: int) -> str:
|
|
|
|
|
"""``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``."""
|
|
|
|
|
return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _nombre_guia(guia: dict) -> str:
|
|
|
|
|
return f"{guia.get('serie', '')}-{guia.get('numero', '')}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Motivos de rechazo de una factura (validación contra SAP)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float,
|
|
|
|
|
guias: list[str], varias_lineas: bool) -> Observacion:
|
|
|
|
|
filas = []
|
|
|
|
|
if varias_lineas:
|
|
|
|
|
filas.append(("Línea", str(numero_linea)))
|
|
|
|
|
filas += [
|
|
|
|
|
("Guías", ", ".join(guias)),
|
|
|
|
|
("Importe en su factura", moneda(importe_factura)),
|
|
|
|
|
("Importe esperado", moneda(importe_esperado)),
|
|
|
|
|
]
|
|
|
|
|
return Observacion(
|
|
|
|
|
"precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas),
|
|
|
|
|
"Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def guia_no_encontrada(guia: str) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),),
|
|
|
|
|
"Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
|
|
|
|
|
filas = [("Línea", str(numero_linea))]
|
|
|
|
|
if descripcion:
|
|
|
|
|
filas.append(("Descripción", descripcion[:60]))
|
|
|
|
|
return Observacion(
|
|
|
|
|
"linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas),
|
|
|
|
|
"Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 12:08:38 -05:00
|
|
|
def factura_ya_registrada(numero_comprobante: str) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"factura_ya_registrada", "Esta factura ya está registrada en nuestro sistema",
|
|
|
|
|
(("Comprobante", numero_comprobante),),
|
|
|
|
|
"No necesita enviarla otra vez. Si tiene dudas sobre su estado, escríbanos indicando el código de seguimiento.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def cantidad_cero(numero_linea: int) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
|
|
|
|
|
"Corrija la cantidad de la línea y emita el comprobante nuevamente.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Motivos de rechazo de un correo sin comprobante válido
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
|
|
|
|
|
(("Archivos recibidos", str(archivos_recibidos)),),
|
|
|
|
|
"Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def falta_xml(pdfs_recibidos: int) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
|
|
|
|
|
"Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sin_factura(documentos_recibidos: list[str]) -> Observacion:
|
|
|
|
|
return Observacion(
|
|
|
|
|
"sin_factura", "No encontramos una factura electrónica en su correo",
|
|
|
|
|
(("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
|
|
|
|
|
"Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Resultado de la validación de precios → observaciones
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def _sin_datos_sap(item: dict) -> bool:
|
|
|
|
|
"""La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
|
|
|
|
|
return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
|
|
|
|
|
"""
|
|
|
|
|
Observaciones de una factura a partir de los resultados de ``validate_prices``
|
|
|
|
|
(uno por línea). Una línea que no coincide da una observación por cada guía
|
|
|
|
|
que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
|
|
|
|
|
"""
|
|
|
|
|
lineas = factura.get("lineas", [])
|
|
|
|
|
varias_lineas = len(lineas) > 1
|
|
|
|
|
observaciones: list[Observacion] = []
|
|
|
|
|
|
|
|
|
|
for i, resultado in enumerate(resultados_val):
|
|
|
|
|
if resultado.get("ok"):
|
|
|
|
|
continue
|
|
|
|
|
numero = i + 1
|
|
|
|
|
linea = lineas[i] if i < len(lineas) else {}
|
|
|
|
|
etiqueta = resultado.get("label", "")
|
|
|
|
|
|
|
|
|
|
if etiqueta == "Cantidad cero, sin validar":
|
|
|
|
|
observaciones.append(cantidad_cero(numero))
|
|
|
|
|
elif etiqueta == "Sin guía de referencia":
|
|
|
|
|
observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
|
|
|
|
|
else:
|
|
|
|
|
guias_lt = resultado.get("guias_lt", [])
|
|
|
|
|
no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
|
|
|
|
|
observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
|
|
|
|
|
if len(no_encontradas) < len(guias_lt):
|
|
|
|
|
observaciones.append(precio_no_coincide(
|
|
|
|
|
numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
|
|
|
|
|
[_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
|
|
|
|
|
))
|
|
|
|
|
return observaciones
|