diff --git a/backend/app/main.py b/backend/app/main.py
index 4c77a1e..ebdd712 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1067,76 +1067,294 @@ def complete_inspection(
inspection.status = "completed"
inspection.completed_at = datetime.utcnow()
- # Generar PDF con miniaturas de imágenes y subir a MinIO
+ # Generar PDF profesional con diseño mejorado
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
- from reportlab.lib.units import inch
- from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage
+ from reportlab.lib.units import inch, mm
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak, KeepTogether
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
- from reportlab.lib.enums import TA_CENTER
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
from io import BytesIO
import requests
+
buffer = BytesIO()
- doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
+ doc = SimpleDocTemplate(
+ buffer,
+ pagesize=A4,
+ rightMargin=15*mm,
+ leftMargin=15*mm,
+ topMargin=20*mm,
+ bottomMargin=20*mm
+ )
+
elements = []
styles = getSampleStyleSheet()
- title_style = styles['Title']
- normal_style = styles['Normal']
- header_style = ParagraphStyle('Header', parent=styles['Heading2'], alignment=TA_CENTER, spaceAfter=12)
- # Portada
- elements.append(Paragraph(f"Informe de Inspección #{inspection.id}", title_style))
- elements.append(Spacer(1, 12))
- elements.append(Paragraph(f"Vehículo: {inspection.vehicle_brand or ''} {inspection.vehicle_model or ''} - Placa: {inspection.vehicle_plate}", normal_style))
- elements.append(Paragraph(f"Cliente: {inspection.client_name or ''}", normal_style))
+
+ # Estilos personalizados
+ title_style = ParagraphStyle(
+ 'CustomTitle',
+ parent=styles['Heading1'],
+ fontSize=24,
+ textColor=colors.HexColor('#1e3a8a'),
+ spaceAfter=6,
+ alignment=TA_CENTER,
+ fontName='Helvetica-Bold'
+ )
+
+ subtitle_style = ParagraphStyle(
+ 'CustomSubtitle',
+ parent=styles['Normal'],
+ fontSize=11,
+ textColor=colors.HexColor('#475569'),
+ spaceAfter=20,
+ alignment=TA_CENTER
+ )
+
+ section_header_style = ParagraphStyle(
+ 'SectionHeader',
+ parent=styles['Heading2'],
+ fontSize=14,
+ textColor=colors.HexColor('#1e40af'),
+ spaceBefore=16,
+ spaceAfter=10,
+ fontName='Helvetica-Bold',
+ borderWidth=0,
+ borderColor=colors.HexColor('#3b82f6'),
+ borderPadding=6,
+ backColor=colors.HexColor('#eff6ff')
+ )
+
+ info_style = ParagraphStyle(
+ 'InfoStyle',
+ parent=styles['Normal'],
+ fontSize=10,
+ textColor=colors.HexColor('#334155'),
+ spaceAfter=4
+ )
+
+ small_style = ParagraphStyle(
+ 'SmallStyle',
+ parent=styles['Normal'],
+ fontSize=8,
+ textColor=colors.HexColor('#64748b')
+ )
+
+ # Obtener datos
mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first()
checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first()
- elements.append(Paragraph(f"Mecánico: {mechanic.full_name if mechanic else ''}", normal_style))
- elements.append(Paragraph(f"Checklist: {checklist.name if checklist else ''}", normal_style))
- elements.append(Paragraph(f"Fecha: {inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else ''}", normal_style))
- elements.append(Spacer(1, 18))
- # Tabla de respuestas con miniaturas
- answers = db.query(models.Answer).options(joinedload(models.Answer.media_files)).join(models.Question).filter(models.Answer.inspection_id == inspection_id).order_by(models.Question.section, models.Question.order).all()
- table_data = [["Sección", "Pregunta", "Respuesta", "Estado", "Comentario", "Miniaturas"]]
+
+ # ===== PORTADA =====
+ elements.append(Spacer(1, 10*mm))
+ elements.append(Paragraph("📋 INFORME DE INSPECCIÓN VEHICULAR", title_style))
+ elements.append(Paragraph(f"N° {inspection.id}", subtitle_style))
+ elements.append(Spacer(1, 5*mm))
+
+ # Cuadro de información del vehículo
+ vehicle_data = [
+ [Paragraph("🚗 INFORMACIÓN DEL VEHÍCULO", info_style)],
+ [Table([
+ [Paragraph("Placa:", small_style), Paragraph(f"{inspection.vehicle_plate}", info_style)],
+ [Paragraph("Marca:", small_style), Paragraph(f"{inspection.vehicle_brand or 'N/A'}", info_style)],
+ [Paragraph("Modelo:", small_style), Paragraph(f"{inspection.vehicle_model or 'N/A'}", info_style)],
+ [Paragraph("Kilometraje:", small_style), Paragraph(f"{inspection.vehicle_km or 'N/A'} km", info_style)]
+ ], colWidths=[30*mm, 50*mm])]
+ ]
+
+ vehicle_table = Table(vehicle_data, colWidths=[85*mm])
+ vehicle_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#dbeafe')),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1e40af')),
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, 0), 11),
+ ('PADDING', (0, 0), (-1, -1), 8),
+ ('BACKGROUND', (0, 1), (-1, -1), colors.white),
+ ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#93c5fd')),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ]))
+
+ # Cuadro de información del cliente e inspección
+ inspection_data = [
+ [Paragraph("👤 INFORMACIÓN DEL CLIENTE", info_style)],
+ [Table([
+ [Paragraph("Cliente:", small_style), Paragraph(f"{inspection.client_name or 'N/A'}", info_style)],
+ [Paragraph("OR N°:", small_style), Paragraph(f"{inspection.or_number or 'N/A'}", info_style)],
+ [Paragraph("Mecánico:", small_style), Paragraph(f"{mechanic.full_name if mechanic else 'N/A'}", info_style)],
+ [Paragraph("Cód. Operario:", small_style), Paragraph(f"{inspection.mechanic_employee_code or 'N/A'}", info_style)],
+ [Paragraph("Fecha:", small_style), Paragraph(f"{inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'}", info_style)]
+ ], colWidths=[30*mm, 50*mm])]
+ ]
+
+ inspection_info_table = Table(inspection_data, colWidths=[85*mm])
+ inspection_info_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#dcfce7')),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#15803d')),
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, 0), 11),
+ ('PADDING', (0, 0), (-1, -1), 8),
+ ('BACKGROUND', (0, 1), (-1, -1), colors.white),
+ ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#86efac')),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ]))
+
+ # Tabla con ambos cuadros lado a lado
+ info_table = Table([[vehicle_table, inspection_info_table]], colWidths=[90*mm, 90*mm])
+ info_table.setStyle(TableStyle([
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ]))
+ elements.append(info_table)
+ elements.append(Spacer(1, 8*mm))
+
+ # Resumen de puntuación
+ percentage = inspection.percentage
+ score_color = colors.HexColor('#22c55e') if percentage >= 80 else colors.HexColor('#eab308') if percentage >= 60 else colors.HexColor('#ef4444')
+
+ score_data = [
+ [
+ Paragraph("Puntuación Total", info_style),
+ Paragraph(f"{inspection.score} / {inspection.max_score}", info_style),
+ Paragraph(f"{percentage:.1f}%", info_style),
+ Paragraph(f"⚠️ {inspection.flagged_items_count} Críticos", info_style)
+ ]
+ ]
+
+ score_table = Table(score_data, colWidths=[45*mm, 45*mm, 45*mm, 45*mm])
+ score_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, -1), score_color),
+ ('TEXTCOLOR', (0, 0), (-1, -1), colors.white),
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('FONTNAME', (0, 0), (-1, -1), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 12),
+ ('PADDING', (0, 0), (-1, -1), 10),
+ ('BOX', (0, 0), (-1, -1), 2, colors.white),
+ ]))
+ elements.append(score_table)
+ elements.append(PageBreak())
+
+ # ===== DETALLE DE RESPUESTAS =====
+ elements.append(Paragraph("📝 DETALLE DE LA INSPECCIÓN", section_header_style))
+ elements.append(Spacer(1, 5*mm))
+
+ # Obtener respuestas agrupadas por sección
+ answers = db.query(models.Answer).options(
+ joinedload(models.Answer.media_files),
+ joinedload(models.Answer.question)
+ ).join(models.Question).filter(
+ models.Answer.inspection_id == inspection_id
+ ).order_by(
+ models.Question.section,
+ models.Question.order
+ ).all()
+
+ current_section = None
for ans in answers:
question = ans.question
- media_imgs = []
- for media in ans.media_files:
- if media.file_type == "image":
- try:
- img_resp = requests.get(media.file_path)
- if img_resp.status_code == 200:
- img_bytes = BytesIO(img_resp.content)
- rl_img = RLImage(img_bytes, width=0.7*inch, height=0.7*inch)
- media_imgs.append(rl_img)
- except Exception as e:
- print(f"Error cargando imagen {media.file_path}: {e}")
- row = [
- question.section or "",
- question.text,
- ans.answer_value,
- ans.status,
- ans.comment or "",
- media_imgs if media_imgs else ""
- ]
- table_data.append(row)
- table = Table(table_data, colWidths=[1.2*inch, 2.5*inch, 1*inch, 0.8*inch, 2*inch, 1.5*inch])
- table.setStyle(TableStyle([
- ('BACKGROUND', (0,0), (-1,0), colors.lightgrey),
- ('TEXTCOLOR', (0,0), (-1,0), colors.black),
- ('ALIGN', (0,0), (-1,-1), 'LEFT'),
- ('VALIGN', (0,0), (-1,-1), 'TOP'),
- ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
- ('FONTSIZE', (0,0), (-1,0), 10),
- ('BOTTOMPADDING', (0,0), (-1,0), 8),
- ('GRID', (0,0), (-1,-1), 0.5, colors.grey),
- ]))
- elements.append(table)
- elements.append(Spacer(1, 18))
- elements.append(Paragraph(f"Generado por Checklist Inteligente - {datetime.now().strftime('%d/%m/%Y %H:%M')}", header_style))
+
+ # Nueva sección
+ if question.section != current_section:
+ if current_section is not None:
+ elements.append(Spacer(1, 5*mm))
+ current_section = question.section
+ elements.append(Paragraph(f"▶ {question.section or 'General'}", section_header_style))
+ elements.append(Spacer(1, 3*mm))
+
+ # Estado visual
+ status_colors = {
+ 'ok': colors.HexColor('#22c55e'),
+ 'warning': colors.HexColor('#eab308'),
+ 'critical': colors.HexColor('#ef4444')
+ }
+ status_icons = {
+ 'ok': '✓',
+ 'warning': '⚠',
+ 'critical': '✕'
+ }
+ status_color = status_colors.get(ans.status, colors.HexColor('#64748b'))
+ status_icon = status_icons.get(ans.status, '●')
+
+ # Tabla de pregunta/respuesta
+ question_data = []
+
+ # Fila 1: Pregunta
+ question_data.append([
+ Paragraph(f"{status_icon} {question.text}", info_style),
+ ])
+
+ # Fila 2: Respuesta y estado
+ answer_text = ans.answer_value or 'Sin respuesta'
+ question_data.append([
+ Table([
+ [
+ Paragraph(f"Respuesta: {answer_text}", small_style),
+ Paragraph(f"Estado: {ans.status.upper()}", ParagraphStyle('status', parent=small_style, textColor=status_color, fontName='Helvetica-Bold'))
+ ]
+ ], colWidths=[120*mm, 50*mm])
+ ])
+
+ # Fila 3: Comentario (si existe)
+ if ans.comment:
+ question_data.append([
+ Paragraph(f"Comentario: {ans.comment}", small_style)
+ ])
+
+ # Fila 4: Imágenes (si existen)
+ if ans.media_files:
+ media_imgs = []
+ for media in ans.media_files:
+ if media.file_type == "image":
+ try:
+ img_resp = requests.get(media.file_path, timeout=10)
+ if img_resp.status_code == 200:
+ img_bytes = BytesIO(img_resp.content)
+ rl_img = RLImage(img_bytes, width=25*mm, height=25*mm)
+ media_imgs.append(rl_img)
+ except Exception as e:
+ print(f"Error cargando imagen {media.file_path}: {e}")
+
+ if media_imgs:
+ # Crear tabla de miniaturas (máximo 6 por fila)
+ img_rows = []
+ for i in range(0, len(media_imgs), 6):
+ img_rows.append(media_imgs[i:i+6])
+
+ img_table = Table(img_rows)
+ img_table.setStyle(TableStyle([
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('PADDING', (0, 0), (-1, -1), 2),
+ ]))
+ question_data.append([img_table])
+
+ # Tabla de la pregunta completa
+ q_table = Table(question_data, colWidths=[180*mm])
+ q_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
+ ('PADDING', (0, 0), (-1, -1), 6),
+ ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#cbd5e1')),
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ]))
+
+ elements.append(KeepTogether(q_table))
+ elements.append(Spacer(1, 3*mm))
+
+ # ===== FOOTER =====
+ elements.append(Spacer(1, 10*mm))
+ elements.append(Paragraph(
+ f"Documento generado automáticamente por Checklist Inteligente el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}",
+ ParagraphStyle('footer', parent=small_style, alignment=TA_CENTER, textColor=colors.HexColor('#94a3b8'))
+ ))
+
+ # Generar PDF
try:
doc.build(elements)
except Exception as e:
- print(f"Error al generar PDF: {e}")
+ print(f"❌ Error al generar PDF: {e}")
+ import traceback
+ traceback.print_exc()
+
buffer.seek(0)
now = datetime.now()
folder = f"{now.year}/{now.month:02d}"