"""
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()