Actualziar modelo de PDF
This commit is contained in:
@@ -1067,76 +1067,294 @@ def complete_inspection(
|
|||||||
inspection.status = "completed"
|
inspection.status = "completed"
|
||||||
inspection.completed_at = datetime.utcnow()
|
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.pagesizes import A4
|
||||||
from reportlab.lib import colors
|
from reportlab.lib import colors
|
||||||
from reportlab.lib.units import inch
|
from reportlab.lib.units import inch, mm
|
||||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak, KeepTogether
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
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
|
from io import BytesIO
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
buffer = BytesIO()
|
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 = []
|
elements = []
|
||||||
styles = getSampleStyleSheet()
|
styles = getSampleStyleSheet()
|
||||||
title_style = styles['Title']
|
|
||||||
normal_style = styles['Normal']
|
# Estilos personalizados
|
||||||
header_style = ParagraphStyle('Header', parent=styles['Heading2'], alignment=TA_CENTER, spaceAfter=12)
|
title_style = ParagraphStyle(
|
||||||
# Portada
|
'CustomTitle',
|
||||||
elements.append(Paragraph(f"Informe de Inspección #{inspection.id}", title_style))
|
parent=styles['Heading1'],
|
||||||
elements.append(Spacer(1, 12))
|
fontSize=24,
|
||||||
elements.append(Paragraph(f"Vehículo: {inspection.vehicle_brand or ''} {inspection.vehicle_model or ''} - Placa: {inspection.vehicle_plate}", normal_style))
|
textColor=colors.HexColor('#1e3a8a'),
|
||||||
elements.append(Paragraph(f"Cliente: {inspection.client_name or ''}", normal_style))
|
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()
|
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()
|
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))
|
# ===== PORTADA =====
|
||||||
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, 10*mm))
|
||||||
elements.append(Spacer(1, 18))
|
elements.append(Paragraph("📋 INFORME DE INSPECCIÓN VEHICULAR", title_style))
|
||||||
# Tabla de respuestas con miniaturas
|
elements.append(Paragraph(f"N° {inspection.id}", subtitle_style))
|
||||||
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()
|
elements.append(Spacer(1, 5*mm))
|
||||||
table_data = [["Sección", "Pregunta", "Respuesta", "Estado", "Comentario", "Miniaturas"]]
|
|
||||||
|
# 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:
|
for ans in answers:
|
||||||
question = ans.question
|
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 = []
|
media_imgs = []
|
||||||
for media in ans.media_files:
|
for media in ans.media_files:
|
||||||
if media.file_type == "image":
|
if media.file_type == "image":
|
||||||
try:
|
try:
|
||||||
img_resp = requests.get(media.file_path)
|
img_resp = requests.get(media.file_path, timeout=10)
|
||||||
if img_resp.status_code == 200:
|
if img_resp.status_code == 200:
|
||||||
img_bytes = BytesIO(img_resp.content)
|
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)
|
media_imgs.append(rl_img)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error cargando imagen {media.file_path}: {e}")
|
print(f"Error cargando imagen {media.file_path}: {e}")
|
||||||
row = [
|
|
||||||
question.section or "",
|
if media_imgs:
|
||||||
question.text,
|
# Crear tabla de miniaturas (máximo 6 por fila)
|
||||||
ans.answer_value,
|
img_rows = []
|
||||||
ans.status,
|
for i in range(0, len(media_imgs), 6):
|
||||||
ans.comment or "",
|
img_rows.append(media_imgs[i:i+6])
|
||||||
media_imgs if media_imgs else ""
|
|
||||||
]
|
img_table = Table(img_rows)
|
||||||
table_data.append(row)
|
img_table.setStyle(TableStyle([
|
||||||
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'),
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||||
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
|
('PADDING', (0, 0), (-1, -1), 2),
|
||||||
('FONTSIZE', (0,0), (-1,0), 10),
|
|
||||||
('BOTTOMPADDING', (0,0), (-1,0), 8),
|
|
||||||
('GRID', (0,0), (-1,-1), 0.5, colors.grey),
|
|
||||||
]))
|
]))
|
||||||
elements.append(table)
|
question_data.append([img_table])
|
||||||
elements.append(Spacer(1, 18))
|
|
||||||
elements.append(Paragraph(f"Generado por Checklist Inteligente - {datetime.now().strftime('%d/%m/%Y %H:%M')}", header_style))
|
# 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:
|
try:
|
||||||
doc.build(elements)
|
doc.build(elements)
|
||||||
except Exception as e:
|
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)
|
buffer.seek(0)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
folder = f"{now.year}/{now.month:02d}"
|
folder = f"{now.year}/{now.month:02d}"
|
||||||
|
|||||||
Reference in New Issue
Block a user