Spaces:
Sleeping
Sleeping
| """ | |
| 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"<b>Komentar:</b> {visit.comments}", styles["body"] | |
| )) | |
| story.append(Spacer(1, 2 * mm)) | |
| if visit.suggestions: | |
| story.append(Paragraph( | |
| f"<b>Saran:</b> {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() | |