""" PDF Service — Visit Report Generation (ReportLab) Generates a structured PDF report for a visit including: - Visit details (title, location, date, status) - Checklist items with check/uncheck status and notes - Photo metadata table - Summary statistics Dependency: reportlab, pillow Install: pip install reportlab pillow """ import io import logging import uuid from datetime import datetime from typing import Optional from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm from reportlab.platypus import ( HRFlowable, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from models.visit_model import Visit from models.checklist_model import Checklist from models.photo_model import Photo from models.assessment_model import Assessment logger = logging.getLogger(__name__) # ── Color Palette ────────────────────────────────────────────────────────── PRIMARY = colors.HexColor("#1565C0") # Material Blue 800 SUCCESS = colors.HexColor("#2E7D32") # Green 800 WARNING = colors.HexColor("#F57F17") # Amber 800 DANGER = colors.HexColor("#C62828") # Red 800 GREY_LIGHT = colors.HexColor("#F5F5F5") GREY_TEXT = colors.HexColor("#757575") ORANGE = colors.HexColor("#F57F17") PURPLE = colors.HexColor("#7B1FA2") # Assessment category colors CAT_COLORS = { "A": colors.HexColor("#1976D2"), # Blue "B": colors.HexColor("#F57C00"), # Orange "C": colors.HexColor("#00897B"), # Teal "D": colors.HexColor("#2E7D32"), # Green "E": colors.HexColor("#7B1FA2"), # Purple } CAT_LABELS = { "A": "Pelayanan Awal", "B": "Kualitas Produk", "C": "Kebersihan & Suasana", "D": "Kepatuhan SOP", "E": "Keseluruhan Pengalaman", } class PDFService: """Generates PDF visit reports using ReportLab.""" def _build_styles(self): """Create custom paragraph styles for the report.""" base = getSampleStyleSheet() return { "title": ParagraphStyle( "ReportTitle", parent=base["Title"], fontSize=20, textColor=PRIMARY, spaceAfter=4, ), "subtitle": ParagraphStyle( "Subtitle", parent=base["Normal"], fontSize=10, textColor=GREY_TEXT, spaceAfter=12, ), "section": ParagraphStyle( "SectionHeader", parent=base["Heading2"], fontSize=12, textColor=PRIMARY, spaceBefore=12, spaceAfter=6, ), "body": ParagraphStyle( "Body", parent=base["Normal"], fontSize=9, leading=14, ), "label": ParagraphStyle( "Label", parent=base["Normal"], fontSize=8, textColor=GREY_TEXT, ), } def _status_color(self, status: str) -> colors.Color: return { "completed": SUCCESS, "in_progress": WARNING, "cancelled": DANGER, }.get(status, GREY_TEXT) def _score_color(self, score: float) -> colors.Color: """Color based on normalized score.""" if score >= 4.5: return SUCCESS elif score >= 3.5: return colors.HexColor("#558B2F") elif score >= 2.5: return WARNING else: return DANGER async def generate_visit_report( self, db: AsyncSession, visit_id: uuid.UUID ) -> Optional[bytes]: """ Generate a PDF report for a visit. Args: db: Async DB session visit_id: UUID of the visit Returns: PDF as bytes, or None if visit not found """ logger.info(f"Generating PDF for visit: {visit_id}") # ── Load visit with relations ── result = await db.execute( select(Visit) .options( selectinload(Visit.checklists), selectinload(Visit.photos), selectinload(Visit.assessments), ) .where(Visit.id == visit_id) ) visit = result.scalar_one_or_none() if not visit: logger.warning(f"Visit {visit_id} not found for PDF generation") return None # ── Build PDF in memory ── buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=20 * mm, leftMargin=20 * mm, topMargin=20 * mm, bottomMargin=20 * mm, ) styles = self._build_styles() story = [] # ── Header ──────────────────────────────────────────── story.append(Paragraph(visit.title, styles["title"])) story.append(Paragraph( f"Laporan Kunjungan • Dicetak {datetime.utcnow().strftime('%d %b %Y %H:%M')} UTC", styles["subtitle"], )) story.append(HRFlowable(width="100%", thickness=1, color=PRIMARY)) story.append(Spacer(1, 6 * mm)) # ── Visit Details Table ──────────────────────────────── story.append(Paragraph("Informasi Kunjungan", styles["section"])) status_label = visit.status.replace("_", " ").title() details_data = [ ["Nama Outlet", visit.location or "—"], ["Nama Ghost Shopper", visit.ghost_shopper or "—"], ["Tanggal Kunjungan", visit.visit_date.strftime("%d %b %Y %H:%M")], ["Tipe Kunjungan", visit.visit_type.replace('_', ' ').title() if visit.visit_type else "Dine In"], ["Status Laporan", status_label], ] details_table = Table( details_data, colWidths=[45 * mm, 125 * mm], ) details_table.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (0, -1), GREY_LIGHT), ("TEXTCOLOR", (0, 0), (-1, -1), colors.black), ("FONTSIZE", (0, 0), (-1, -1), 9), ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E0E0E0")), ("LEFTPADDING", (0, 0), (-1, -1), 8), ("RIGHTPADDING", (0, 0), (-1, -1), 8), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING",(0, 0), (-1, -1), 5), # Status cell color ("TEXTCOLOR", (1, 4), (1, 4), self._status_color(visit.status)), ("FONTNAME", (1, 4), (1, 4), "Helvetica-Bold"), ])) story.append(details_table) story.append(Spacer(1, 6 * mm)) # ── Ghost Shopper Assessment Section ────────────────── assessments = visit.assessments or [] if assessments: # Group by category cat_groups = {} for a in assessments: cat_groups.setdefault(a.category, []).append(a) total_score = sum(a.score for a in assessments) overall_avg = round(total_score / len(assessments), 2) story.append(Paragraph( f"Hasil Penilaian Ghost Shopper (Skor Rata-Rata: {overall_avg}/5.0)", styles["section"], )) # Category summary table cat_header = [["Kategori", "Deskripsi", "Jml Item", "Rata-Rata Skor"]] for cat_key in sorted(cat_groups.keys()): items = cat_groups[cat_key] cat_avg = round(sum(i.score for i in items) / len(items), 2) cat_header.append([ cat_key, CAT_LABELS.get(cat_key, cat_key), str(len(items)), str(cat_avg), ]) cat_header.append(["", "KESELURUHAN", str(len(assessments)), str(overall_avg)]) cat_table = Table(cat_header, colWidths=[18*mm, 70*mm, 20*mm, 62*mm]) cat_styles = [ ("BACKGROUND", (0, 0), (-1, 0), PRIMARY), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E0E0E0")), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING",(0, 0), (-1, -1), 4), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, GREY_LIGHT]), # Overall row bold ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), ("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#E3F2FD")), ] # Color-code each category score for i, cat_key in enumerate(sorted(cat_groups.keys()), 1): items = cat_groups[cat_key] cat_avg = sum(it.score for it in items) / len(items) cat_styles.append(("TEXTCOLOR", (3, i), (3, i), self._score_color(cat_avg))) cat_styles.append(("FONTNAME", (3, i), (3, i), "Helvetica-Bold")) cat_styles.append(("TEXTCOLOR", (0, i), (0, i), CAT_COLORS.get(cat_key, GREY_TEXT))) cat_styles.append(("FONTNAME", (0, i), (0, i), "Helvetica-Bold")) # Overall score color cat_styles.append(("TEXTCOLOR", (3, -1), (3, -1), self._score_color(overall_avg))) cat_table.setStyle(TableStyle(cat_styles)) story.append(cat_table) story.append(Spacer(1, 4 * mm)) # Detailed items per category for cat_key in sorted(cat_groups.keys()): items = sorted(cat_groups[cat_key], key=lambda x: x.item_no) cat_avg = round(sum(i.score for i in items) / len(items), 2) cat_color = CAT_COLORS.get(cat_key, PRIMARY) story.append(Paragraph( f"{cat_key}. {CAT_LABELS.get(cat_key, cat_key)} — Rata-Rata: {cat_avg}/5.0", ParagraphStyle( "CatSub", parent=getSampleStyleSheet()["Normal"], fontSize=9, textColor=cat_color, fontName="Helvetica-Bold", spaceBefore=4, spaceAfter=3, ), )) cell_style = ParagraphStyle( "TableCell", parent=getSampleStyleSheet()["Normal"], fontSize=8, leading=10, ) item_data = [["No.", "Aspek Penilaian", "Skor/Nilai", "Keterangan"]] for it in items: if cat_key == "D": score_text = "Ya" if it.score >= 1.0 else "Tidak" else: score_text = str(round(it.score, 1)) item_data.append([ str(it.item_no), Paragraph(it.criteria, cell_style), score_text, Paragraph(it.raw_value or "—", cell_style), ]) item_table = Table(item_data, colWidths=[10*mm, 85*mm, 25*mm, 50*mm]) item_styles = [ ("BACKGROUND", (0, 0), (-1, 0), cat_color), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E0E0E0")), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, GREY_LIGHT]), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING",(0, 0), (-1, -1), 3), ("VALIGN", (0, 0), (-1, -1), "TOP"), ] # Color-code individual scores for i, it in enumerate(items, 1): item_styles.append(("TEXTCOLOR", (2, i), (2, i), self._score_color(it.score))) item_styles.append(("FONTNAME", (2, i), (2, i), "Helvetica-Bold")) item_table.setStyle(TableStyle(item_styles)) story.append(item_table) story.append(Spacer(1, 2 * mm)) story.append(Spacer(1, 4 * mm)) # ── Comments & Suggestions ──────────────────────────── if visit.comments or visit.suggestions: story.append(Paragraph("Komentar & Saran Khusus", styles["section"])) if visit.comments: story.append(Paragraph( f"Komentar: {visit.comments}", styles["body"] )) story.append(Spacer(1, 2 * mm)) if visit.suggestions: story.append(Paragraph( f"Saran: {visit.suggestions}", styles["body"] )) story.append(Spacer(1, 6 * mm)) # ── Checklist Section ───────────────────────────────── checklists = visit.checklists checked_count = sum(1 for c in checklists if c.is_checked) total_count = len(checklists) pct = round(checked_count / total_count * 100) if total_count > 0 else 0 story.append(Paragraph( f"Daftar Periksa (Checklist) ({checked_count}/{total_count} — {pct}% selesai)", styles["section"], )) if checklists: cl_data = [["No", "Status", "Item", "Catatan", "Waktu Cek"]] for i, item in enumerate(checklists, 1): status_mark = "SELESAI" if item.is_checked else "TERTUNDA" checked_at = ( item.checked_at.strftime("%d %b %Y") if item.checked_at else "—" ) cl_data.append([ str(i), status_mark, item.item_name, item.notes or "—", checked_at, ]) cl_table = Table( cl_data, colWidths=[8 * mm, 16 * mm, 60 * mm, 60 * mm, 26 * mm], ) # Build row-level styles for checked/unchecked row_styles = [ ("BACKGROUND", (0, 0), (-1, 0), PRIMARY), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E0E0E0")), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING",(0, 0), (-1, -1), 4), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, GREY_LIGHT]), ] for i, item in enumerate(checklists, 1): color = SUCCESS if item.is_checked else DANGER row_styles.append(("TEXTCOLOR", (1, i), (1, i), color)) row_styles.append(("FONTNAME", (1, i), (1, i), "Helvetica-Bold")) cl_table.setStyle(TableStyle(row_styles)) story.append(cl_table) else: story.append(Paragraph("Tidak ada riwayat daftar periksa (checklist).", styles["body"])) story.append(Spacer(1, 6 * mm)) # ── Photos Section ───────────────────────────────────── story.append(Paragraph( f"Lampiran Bukti Foto (Evidence) — {len(visit.photos)} foto terlampir", styles["section"], )) if visit.photos: ph_data = [["Nama File", "Ukuran", "Tipe", "Waktu Unggah"]] for p in visit.photos: size_str = f"{p.file_size // 1024} KB" if p.file_size else "—" ph_data.append([ p.file_name, size_str, p.mime_type or "—", p.uploaded_at.strftime("%d %b %Y"), ]) ph_table = Table( ph_data, colWidths=[70 * mm, 25 * mm, 35 * mm, 40 * mm], ) ph_table.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), PRIMARY), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E0E0E0")), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, GREY_LIGHT]), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING",(0, 0), (-1, -1), 4), ])) story.append(ph_table) else: story.append(Paragraph("Tidak ada foto terlampir.", styles["body"])) story.append(Spacer(1, 15 * mm)) # ── Signature Section ────────────────────────────────── story.append(Paragraph("Pengesahan Laporan", styles["section"])) story.append(Spacer(1, 5 * mm)) sig_data = [ [f"Telah Dilaksanakan Oleh:\nGhost Shopper", "Disetujui Oleh:\nKoordinator GS / PIC Outlet"], ["", ""], # Empty row for signature image placeholder [f"( {visit.ghost_shopper or '................................'} )", "( ................................ )"] ] sig_table = Table(sig_data, colWidths=[85*mm, 85*mm]) sig_table.setStyle(TableStyle([ ("ALIGN", (0,0), (-1,-1), "CENTER"), ("VALIGN", (0,0), (-1,-1), "BOTTOM"), ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), ("FONTSIZE", (0,0), (-1,0), 9), ("FONTSIZE", (0,2), (-1,2), 10), ("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white]), ("BOTTOMPADDING", (0, 1), (-1, 1), 30), # Tall row for signature box ])) story.append(sig_table) # ── Footer ──────────────────────────────────────────── story.append(Spacer(1, 10 * mm)) story.append(HRFlowable(width="100%", thickness=0.5, color=GREY_TEXT)) story.append(Paragraph( f"Apl_GS — Laporan Kunjungan • {visit.title} • " f"Dicetak {datetime.utcnow().strftime('%d %b %Y')}", ParagraphStyle( "Footer", parent=getSampleStyleSheet()["Normal"], fontSize=7, textColor=GREY_TEXT, alignment=TA_CENTER, ), )) doc.build(story) pdf_bytes = buffer.getvalue() buffer.close() logger.info( f"PDF generated for visit {visit_id} — {len(pdf_bytes)} bytes" ) return pdf_bytes # Singleton pdf_service = PDFService()