417 lines
16 KiB
Python
417 lines
16 KiB
Python
from fpdf import FPDF
|
|
from typing import Dict, List, Any
|
|
import os
|
|
from logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def generar_reporte_pdf(datos: Dict[str, Any], ruta_destino: str) -> bool:
|
|
"""
|
|
Genera un reporte PDF tipo 'snapshot' que replica visualmente la planilla
|
|
completa de la interfaz Tkinter en formato landscape A4.
|
|
|
|
Dibuja la cabecera, la grilla de observaciones con sub-headers (V, MIN, RAW),
|
|
el bloque de resultados estadísticos por columna, y el footer con métricas
|
|
de cadencia y Epicor, todo con celdas bordeadas simulando una grilla industrial.
|
|
|
|
Args:
|
|
datos (Dict[str, Any]): Diccionario integral con claves 'cabecera',
|
|
'elementos' y 'footer', extraído de Plantilla.obtener_datos_limpios().
|
|
ruta_destino (str): Ruta absoluta donde guardar el archivo PDF generado.
|
|
|
|
Returns:
|
|
bool: True si el PDF se generó exitosamente, False en caso de error.
|
|
|
|
Raises:
|
|
OSError: Si la ruta de destino no es accesible o hay problemas de escritura.
|
|
"""
|
|
try:
|
|
cabecera: Dict[str, str] = datos.get("cabecera", {})
|
|
elementos: List[Dict[str, Any]] = datos.get("elementos", [])
|
|
footer: Dict[str, str] = datos.get("footer", {})
|
|
|
|
pdf = FPDF(orientation='L', unit='mm', format='A4')
|
|
pdf.set_auto_page_break(auto=True, margin=10)
|
|
pdf.add_page()
|
|
|
|
# --- Dimensiones de página landscape A4: 297 x 210 mm ---
|
|
page_w: float = 297.0
|
|
margin: float = 10.0
|
|
usable_w: float = page_w - 2 * margin
|
|
|
|
# =====================================================================
|
|
# SECCIÓN 1: TÍTULO PRINCIPAL
|
|
# =====================================================================
|
|
pdf.set_font("Arial", "B", 14)
|
|
pdf.cell(usable_w, 10, "ESTUDIO DE METODOS Y TIEMPOS", border=1, ln=True, align='C')
|
|
pdf.ln(2)
|
|
|
|
# =====================================================================
|
|
# SECCIÓN 2: CABECERA CON BORDES (simulando la tabla superior de la UI)
|
|
# =====================================================================
|
|
_dibujar_cabecera(pdf, cabecera, usable_w)
|
|
pdf.ln(3)
|
|
|
|
# =====================================================================
|
|
# SECCIÓN 3: GRILLA CENTRAL (Observaciones + Resultados)
|
|
# =====================================================================
|
|
if elementos:
|
|
_dibujar_grilla_central(pdf, elementos, usable_w, margin)
|
|
pdf.ln(3)
|
|
|
|
# =====================================================================
|
|
# SECCIÓN 4: FOOTER (Métricas Globales + Epicor)
|
|
# =====================================================================
|
|
_dibujar_footer(pdf, footer, usable_w)
|
|
|
|
# --- Guardar ---
|
|
pdf.output(ruta_destino)
|
|
logger.info(f"Reporte PDF snapshot generado exitosamente en: {ruta_destino}")
|
|
return True
|
|
|
|
except OSError:
|
|
logger.exception(f"Error de sistema al guardar el PDF en la ruta: {ruta_destino}")
|
|
return False
|
|
except Exception:
|
|
logger.exception("Error inesperado durante la generación del reporte PDF snapshot.")
|
|
return False
|
|
|
|
|
|
def _dibujar_cabecera(pdf: FPDF, cabecera: Dict[str, str], usable_w: float) -> None:
|
|
"""
|
|
Dibuja la sección de cabecera del PDF con celdas bordeadas.
|
|
|
|
Simula la tabla superior de la UI con los datos del artículo, operación,
|
|
máquina, operario, fecha y postura.
|
|
|
|
Args:
|
|
pdf (FPDF): Instancia del objeto PDF en construcción.
|
|
cabecera (Dict[str, str]): Datos del encabezado de la planilla.
|
|
usable_w (float): Ancho útil disponible en la página (sin márgenes).
|
|
"""
|
|
# Fila 1: ARTÍCULO (código) | OPERACIÓN: [valor]
|
|
col_art_w: float = 50.0
|
|
col_label_w: float = 40.0
|
|
col_val_w: float = usable_w - col_art_w - col_label_w
|
|
row_h: float = 8.0
|
|
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.cell(col_art_w, row_h, f"ARTICULO: {cabecera.get('articulo_codigo', 'S/C')}", border=1, align='C')
|
|
pdf.cell(col_label_w, row_h, "OPERACION:", border=1, align='R')
|
|
pdf.set_font("Arial", "", 10)
|
|
pdf.cell(col_val_w, row_h, f" {cabecera.get('operacion', 'S/O')}", border=1, ln=True)
|
|
|
|
# Fila 2: Nº Cronometraje | DESCRIPCIÓN: [valor]
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.cell(col_art_w, row_h, f"Crono: {cabecera.get('id_crono', 'N/A')}", border=1, align='C')
|
|
pdf.cell(col_label_w, row_h, "DESCRIPCION:", border=1, align='R')
|
|
pdf.set_font("Arial", "", 10)
|
|
pdf.cell(col_val_w, row_h, f" {cabecera.get('articulo_descripcion', '')}", border=1, ln=True)
|
|
|
|
# Fila 3: Datos operativos en celdas iguales
|
|
n_campos: int = 5
|
|
campo_w: float = usable_w / n_campos
|
|
|
|
campos_operativos = [
|
|
("MAQUINA", cabecera.get("maquina", "S/M")),
|
|
("HM", cabecera.get("herramienta", "S/H")),
|
|
("OPERARIO", cabecera.get("operario", "S/OP")),
|
|
("FECHA", cabecera.get("fecha", "S/F")),
|
|
("POSTURA", cabecera.get("postura", "N/A")),
|
|
]
|
|
|
|
for etiqueta, valor in campos_operativos:
|
|
pdf.set_font("Arial", "B", 8)
|
|
# Dibujamos etiqueta y valor en la misma celda
|
|
pdf.cell(campo_w, row_h, f"{etiqueta}: {valor}", border=1, align='C')
|
|
pdf.ln()
|
|
|
|
|
|
def _dibujar_grilla_central(
|
|
pdf: FPDF,
|
|
elementos: List[Dict[str, Any]],
|
|
usable_w: float,
|
|
margin: float,
|
|
) -> None:
|
|
"""
|
|
Dibuja la grilla central del PDF: nombres de elementos, sub-headers (V, MIN, RAW),
|
|
filas de observaciones y bloque de resultados estadísticos.
|
|
|
|
Args:
|
|
pdf (FPDF): Instancia del objeto PDF en construcción.
|
|
elementos (List[Dict[str, Any]]): Lista de diccionarios con nombre,
|
|
observaciones y resultados por cada elemento/columna dinámica.
|
|
usable_w (float): Ancho útil disponible en la página.
|
|
margin (float): Margen horizontal de la página.
|
|
"""
|
|
n_elem: int = len(elementos)
|
|
if n_elem == 0:
|
|
return
|
|
|
|
# Dimensiones
|
|
label_col_w: float = 40.0 # Columna de etiquetas (izquierda)
|
|
sub_col_w: float = 18.0 # Ancho de cada sub-columna (V, MIN, RAW)
|
|
elem_col_w: float = sub_col_w * 3 # Ancho de un bloque de elemento
|
|
row_h: float = 6.0
|
|
|
|
# Verificar si las columnas caben; si no, reducir sub_col_w proporcionalmente
|
|
espacio_datos: float = usable_w - label_col_w
|
|
if n_elem * elem_col_w > espacio_datos:
|
|
elem_col_w = espacio_datos / n_elem
|
|
sub_col_w = elem_col_w / 3.0
|
|
|
|
# --- Fila de Nombres de Elementos ---
|
|
pdf.set_font("Arial", "B", 8)
|
|
pdf.cell(label_col_w, row_h, "ELEMENTO", border=1, align='C')
|
|
for elem in elementos:
|
|
nombre_truncado = elem["nombre"][:20]
|
|
pdf.cell(elem_col_w, row_h, nombre_truncado, border=1, align='C')
|
|
pdf.ln()
|
|
|
|
# --- Sub-Headers: V | MIN | RAW ---
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.cell(label_col_w, row_h, "", border=1)
|
|
for _ in elementos:
|
|
pdf.cell(sub_col_w, row_h, "V", border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, "MIN", border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, "RAW", border=1, align='C')
|
|
pdf.ln()
|
|
|
|
# --- Filas de Observaciones ---
|
|
# Determinar el máximo de observaciones entre todos los elementos
|
|
max_obs: int = max(
|
|
(len(elem.get("observaciones", [])) for elem in elementos),
|
|
default=0
|
|
)
|
|
|
|
pdf.set_font("Arial", "", 7)
|
|
for row_idx in range(max_obs):
|
|
# Verificar si necesitamos nueva página
|
|
if pdf.get_y() + row_h > 200.0:
|
|
pdf.add_page()
|
|
# Re-dibujar headers en nueva página
|
|
pdf.set_font("Arial", "B", 8)
|
|
pdf.cell(label_col_w, row_h, "ELEMENTO", border=1, align='C')
|
|
for elem in elementos:
|
|
nombre_truncado = elem["nombre"][:20]
|
|
pdf.cell(elem_col_w, row_h, nombre_truncado, border=1, align='C')
|
|
pdf.ln()
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.cell(label_col_w, row_h, "", border=1)
|
|
for _ in elementos:
|
|
pdf.cell(sub_col_w, row_h, "V", border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, "MIN", border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, "RAW", border=1, align='C')
|
|
pdf.ln()
|
|
pdf.set_font("Arial", "", 7)
|
|
|
|
# Etiqueta de fila (Obs N)
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.cell(label_col_w, row_h, f"Obs {row_idx + 1}", border=1, align='C')
|
|
pdf.set_font("Arial", "", 7)
|
|
|
|
for elem in elementos:
|
|
obs_list = elem.get("observaciones", [])
|
|
if row_idx < len(obs_list):
|
|
obs = obs_list[row_idx]
|
|
pdf.cell(sub_col_w, row_h, str(obs.get("valoracion", "-")), border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, str(obs.get("min", "-")), border=1, align='C')
|
|
pdf.cell(sub_col_w, row_h, str(obs.get("raw", "-")), border=1, align='C')
|
|
else:
|
|
# Celdas vacías para mantener alineación
|
|
pdf.cell(sub_col_w, row_h, "", border=1)
|
|
pdf.cell(sub_col_w, row_h, "", border=1)
|
|
pdf.cell(sub_col_w, row_h, "", border=1)
|
|
pdf.ln()
|
|
|
|
# --- Separador Visual ---
|
|
pdf.set_draw_color(0, 0, 0)
|
|
pdf.set_line_width(0.5)
|
|
x_start: float = margin
|
|
x_end: float = margin + label_col_w + n_elem * elem_col_w
|
|
y_pos: float = pdf.get_y()
|
|
pdf.line(x_start, y_pos, x_end, y_pos)
|
|
pdf.set_line_width(0.2)
|
|
pdf.ln(1)
|
|
|
|
# --- Bloque de Resultados Estadísticos ---
|
|
etiquetas_resultados = [
|
|
("N de Observaciones", "n_observaciones"),
|
|
("Elementos medidos", "elementos_medidos"),
|
|
("Lote frecuencial", "lote_frecuencial"),
|
|
("Tiempo MEDIO", "tiempo_medio"),
|
|
("Desvio estandar", "desvio_estandar"),
|
|
("Obs. Necesarias", "obs_necesarias"),
|
|
("Rango mediciones", "rango"),
|
|
("Suplemento", "suplemento"),
|
|
("Tiempo Normal", "tiempo_normal"),
|
|
("Tiempo Estandar", "tiempo_estandar"),
|
|
]
|
|
|
|
for etiqueta, clave in etiquetas_resultados:
|
|
# Verificar paginación
|
|
if pdf.get_y() + row_h > 200.0:
|
|
pdf.add_page()
|
|
|
|
# Estilo especial para T.Normal y T.Estándar
|
|
if clave in ("tiempo_normal", "tiempo_estandar"):
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.set_fill_color(198, 239, 206) # Verde claro (#C6EFCE)
|
|
elif clave == "suplemento":
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.set_fill_color(255, 242, 204) # Amarillo claro (#FFF2CC)
|
|
else:
|
|
pdf.set_font("Arial", "B", 7)
|
|
pdf.set_fill_color(255, 255, 255)
|
|
|
|
pdf.cell(label_col_w, row_h, etiqueta, border=1, align='L', fill=True)
|
|
|
|
pdf.set_font("Arial", "", 7)
|
|
for elem in elementos:
|
|
resultados = elem.get("resultados", {})
|
|
valor = str(resultados.get(clave, ""))
|
|
fill_cell = clave in ("tiempo_normal", "tiempo_estandar", "suplemento")
|
|
pdf.cell(elem_col_w, row_h, valor, border=1, align='C', fill=fill_cell)
|
|
pdf.ln()
|
|
|
|
# Resetear color de relleno
|
|
pdf.set_fill_color(255, 255, 255)
|
|
|
|
|
|
def _dibujar_footer(pdf: FPDF, footer: Dict[str, str], usable_w: float) -> None:
|
|
"""
|
|
Dibuja el footer del PDF con las métricas globales de producción y Epicor.
|
|
|
|
Args:
|
|
pdf (FPDF): Instancia del objeto PDF en construcción.
|
|
footer (Dict[str, str]): Datos del footer (piezas/golpe, cadencias, Epicor).
|
|
usable_w (float): Ancho útil disponible en la página.
|
|
"""
|
|
row_h: float = 7.0
|
|
|
|
# Verificar espacio; el footer necesita ~50mm
|
|
if pdf.get_y() + 50.0 > 200.0:
|
|
pdf.add_page()
|
|
|
|
pdf.ln(2)
|
|
|
|
# --- Fila de separación ---
|
|
pdf.set_font("Arial", "B", 9)
|
|
pdf.cell(usable_w, row_h, "RESUMEN DE OPERACION", border=1, align='C', fill=False)
|
|
pdf.ln()
|
|
|
|
# --- Bloque Izquierdo: Inputs de producción ---
|
|
col_left_w: float = usable_w / 2.0
|
|
col_right_w: float = usable_w / 2.0
|
|
label_w: float = 55.0
|
|
val_w: float = 25.0
|
|
desc_w: float = col_left_w - label_w - val_w
|
|
|
|
# Guardar posición Y para dibujar dos columnas en paralelo
|
|
y_bloque: float = pdf.get_y()
|
|
|
|
# --- Columna Izquierda: Inputs de producción ---
|
|
filas_izq = [
|
|
("Piezas por golpe", footer.get("piezas_por_golpe", "1"), "unidades/golpe"),
|
|
("Articulos por golpe", footer.get("articulos_por_golpe", "1"), "unidades/golpe"),
|
|
("Operarios en maquina", footer.get("operarios_maquina", "1"), ""),
|
|
]
|
|
|
|
pdf.set_xy(pdf.l_margin, y_bloque)
|
|
for etiqueta, valor, unidad in filas_izq:
|
|
x_base: float = pdf.l_margin
|
|
pdf.set_xy(x_base, pdf.get_y())
|
|
pdf.set_font("Arial", "B", 8)
|
|
pdf.cell(label_w, row_h, etiqueta, border=1, align='R')
|
|
pdf.set_font("Arial", "", 8)
|
|
pdf.set_fill_color(255, 242, 204) # Amarillo (#FFF2CC)
|
|
pdf.cell(val_w, row_h, valor, border=1, align='C', fill=True)
|
|
pdf.set_fill_color(255, 255, 255)
|
|
pdf.cell(desc_w, row_h, f" {unidad}", border=1, align='L')
|
|
pdf.ln()
|
|
|
|
y_despues_izq: float = pdf.get_y()
|
|
|
|
# --- Columna Derecha: Métricas calculadas ---
|
|
filas_der = [
|
|
("TE Operacion", footer.get("te_operacion", "0.000"), "min/pieza"),
|
|
("Cadencia", footer.get("cadencia_ph", "0.000"), "piezas/hora"),
|
|
("Cadencia", footer.get("cadencia_h1000", "0.000"), "horas/1000 pzas"),
|
|
]
|
|
|
|
pdf.set_xy(pdf.l_margin + col_left_w, y_bloque)
|
|
label_r_w: float = 40.0
|
|
val_r_w: float = 30.0
|
|
desc_r_w: float = col_right_w - label_r_w - val_r_w
|
|
|
|
for etiqueta, valor, unidad in filas_der:
|
|
x_base = pdf.l_margin + col_left_w
|
|
pdf.set_xy(x_base, pdf.get_y())
|
|
pdf.set_font("Arial", "B", 8)
|
|
pdf.cell(label_r_w, row_h, etiqueta, border=1, align='R')
|
|
pdf.set_font("Arial", "", 8)
|
|
pdf.set_fill_color(198, 239, 206) # Verde (#C6EFCE)
|
|
pdf.cell(val_r_w, row_h, valor, border=1, align='C', fill=True)
|
|
pdf.set_fill_color(255, 255, 255)
|
|
pdf.cell(desc_r_w, row_h, f" {unidad}", border=1, align='L')
|
|
pdf.ln()
|
|
# Reposicionar X para la siguiente fila derecha
|
|
pdf.set_x(pdf.l_margin + col_left_w)
|
|
|
|
y_despues_der: float = pdf.get_y()
|
|
|
|
# Avanzar al máximo de ambas columnas
|
|
pdf.set_y(max(y_despues_izq, y_despues_der))
|
|
pdf.ln(2)
|
|
|
|
# =====================================================================
|
|
# BLOQUE EPICOR (caja destacada con fondo azul claro)
|
|
# =====================================================================
|
|
epicor_w: float = 160.0
|
|
epicor_x: float = pdf.l_margin + (usable_w - epicor_w) / 2.0 # Centrado
|
|
epicor_row_h: float = 8.0
|
|
|
|
pdf.set_xy(epicor_x, pdf.get_y())
|
|
|
|
# Título del bloque Epicor
|
|
pdf.set_font("Arial", "B", 9)
|
|
pdf.set_fill_color(231, 230, 230) # Gris claro (#E7E6E6)
|
|
pdf.cell(epicor_w, epicor_row_h, "EPICOR", border=1, align='C', fill=True)
|
|
pdf.ln()
|
|
|
|
# Estándar Epicor
|
|
pdf.set_xy(epicor_x, pdf.get_y())
|
|
epicor_label_w: float = 50.0
|
|
epicor_val_w: float = 40.0
|
|
epicor_unit_w: float = epicor_w - epicor_label_w - epicor_val_w
|
|
|
|
pdf.set_font("Arial", "B", 9)
|
|
pdf.cell(epicor_label_w, epicor_row_h, "Estandar Epicor", border=1, align='R')
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.set_fill_color(0, 176, 240) # Azul (#00B0F0)
|
|
pdf.set_text_color(255, 255, 255)
|
|
pdf.cell(epicor_val_w, epicor_row_h, footer.get("estandar_epicor", "0.000"), border=1, align='C', fill=True)
|
|
pdf.set_text_color(0, 0, 0)
|
|
pdf.set_fill_color(231, 230, 230)
|
|
pdf.set_font("Arial", "", 9)
|
|
pdf.cell(epicor_unit_w, epicor_row_h, " hh/1000", border=1, align='L', fill=True)
|
|
pdf.ln()
|
|
|
|
# Dotación Epicor
|
|
pdf.set_xy(epicor_x, pdf.get_y())
|
|
pdf.set_font("Arial", "B", 9)
|
|
pdf.cell(epicor_label_w, epicor_row_h, "Dotacion Epicor", border=1, align='R')
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.set_fill_color(0, 176, 240) # Azul (#00B0F0)
|
|
pdf.set_text_color(255, 255, 255)
|
|
pdf.cell(epicor_val_w, epicor_row_h, footer.get("dotacion_epicor", "0.000"), border=1, align='C', fill=True)
|
|
pdf.set_text_color(0, 0, 0)
|
|
pdf.set_fill_color(231, 230, 230)
|
|
pdf.set_font("Arial", "", 9)
|
|
pdf.cell(epicor_unit_w, epicor_row_h, " operarios", border=1, align='L', fill=True)
|
|
pdf.ln()
|
|
|
|
# Resetear colores
|
|
pdf.set_fill_color(255, 255, 255)
|
|
pdf.set_text_color(0, 0, 0) |