Actualziar modelo de PDF
This commit is contained in:
@@ -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("<b>🚗 INFORMACIÓN DEL VEHÍCULO</b>", info_style)],
|
||||
[Table([
|
||||
[Paragraph("<b>Placa:</b>", small_style), Paragraph(f"{inspection.vehicle_plate}", info_style)],
|
||||
[Paragraph("<b>Marca:</b>", small_style), Paragraph(f"{inspection.vehicle_brand or 'N/A'}", info_style)],
|
||||
[Paragraph("<b>Modelo:</b>", small_style), Paragraph(f"{inspection.vehicle_model or 'N/A'}", info_style)],
|
||||
[Paragraph("<b>Kilometraje:</b>", 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("<b>👤 INFORMACIÓN DEL CLIENTE</b>", info_style)],
|
||||
[Table([
|
||||
[Paragraph("<b>Cliente:</b>", small_style), Paragraph(f"{inspection.client_name or 'N/A'}", info_style)],
|
||||
[Paragraph("<b>OR N°:</b>", small_style), Paragraph(f"{inspection.or_number or 'N/A'}", info_style)],
|
||||
[Paragraph("<b>Mecánico:</b>", small_style), Paragraph(f"{mechanic.full_name if mechanic else 'N/A'}", info_style)],
|
||||
[Paragraph("<b>Cód. Operario:</b>", small_style), Paragraph(f"{inspection.mechanic_employee_code or 'N/A'}", info_style)],
|
||||
[Paragraph("<b>Fecha:</b>", 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("<b>Puntuación Total</b>", info_style),
|
||||
Paragraph(f"<b>{inspection.score} / {inspection.max_score}</b>", info_style),
|
||||
Paragraph(f"<b>{percentage:.1f}%</b>", info_style),
|
||||
Paragraph(f"<b>⚠️ {inspection.flagged_items_count} Críticos</b>", 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
|
||||
|
||||
# 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"<b>{status_icon} {question.text}</b>", info_style),
|
||||
])
|
||||
|
||||
# Fila 2: Respuesta y estado
|
||||
answer_text = ans.answer_value or 'Sin respuesta'
|
||||
question_data.append([
|
||||
Table([
|
||||
[
|
||||
Paragraph(f"<b>Respuesta:</b> {answer_text}", small_style),
|
||||
Paragraph(f"<b>Estado:</b> {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"<b>Comentario:</b> {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)
|
||||
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=0.7*inch, height=0.7*inch)
|
||||
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}")
|
||||
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),
|
||||
|
||||
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), '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),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('PADDING', (0, 0), (-1, -1), 2),
|
||||
]))
|
||||
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))
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user