From 70f984bfdf05c51ac80cb760fc0b0ed37485d1ba Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 26 Nov 2025 09:23:35 -0300 Subject: [PATCH] Actualziar modelo de PDF --- backend/app/main.py | 328 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 273 insertions(+), 55 deletions(-) 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}"