""" Reporting utilities for generating PDF documents. Provides PDF builders used by report endpoints (phone book, payments detailed). Uses fpdf2 to generate simple tabular PDFs with automatic pagination. """ from __future__ import annotations from datetime import date from io import BytesIO from typing import Iterable, List, Dict, Any, Tuple from fpdf import FPDF import structlog # Local imports are type-only to avoid circular import costs at import time from .models import Client, Payment logger = structlog.get_logger(__name__) class SimplePDF(FPDF): """Small helper subclass to set defaults and provide header/footer hooks.""" def __init__(self, title: str): super().__init__(orientation="P", unit="mm", format="Letter") self.title = title self.set_auto_page_break(auto=True, margin=15) self.set_margins(left=12, top=12, right=12) def header(self): # type: ignore[override] self.set_font("helvetica", "B", 12) self.cell(0, 8, self.title, ln=1, align="L") self.ln(2) def footer(self): # type: ignore[override] self.set_y(-12) self.set_font("helvetica", size=8) self.set_text_color(120) self.cell(0, 8, f"Page {self.page_no()}", align="R") def _output_pdf_bytes(pdf: FPDF) -> bytes: """Return the PDF content as bytes. fpdf2's output(dest='S') returns a str; encode to latin-1 per fpdf guidance. """ content_str = pdf.output(dest="S") # type: ignore[no-untyped-call] if isinstance(content_str, bytes): return content_str return content_str.encode("latin-1") def build_phone_book_pdf(clients: List[Client]) -> bytes: """Build a Phone Book PDF from a list of `Client` records with phones.""" logger.info("pdf_phone_book_start", count=len(clients)) pdf = SimplePDF(title="Phone Book") pdf.add_page() # Table header pdf.set_font("helvetica", "B", 10) headers = ["Name", "Company", "Phone Type", "Phone Number"] widths = [55, 55, 35, 45] for h, w in zip(headers, widths): pdf.cell(w, 8, h, border=1) pdf.ln(8) pdf.set_font("helvetica", size=10) for client in clients: rows: List[Tuple[str, str, str, str]] = [] name = f"{client.last_name or ''}, {client.first_name or ''}".strip(", ") company = client.company or "" if getattr(client, "phones", None): for p in client.phones: # type: ignore[attr-defined] rows.append((name, company, p.phone_type or "", p.phone_number or "")) else: rows.append((name, company, "", "")) for c0, c1, c2, c3 in rows: pdf.cell(widths[0], 7, c0[:35], border=1) pdf.cell(widths[1], 7, c1[:35], border=1) pdf.cell(widths[2], 7, c2[:18], border=1) pdf.cell(widths[3], 7, c3[:24], border=1) pdf.ln(7) logger.info("pdf_phone_book_done", pages=pdf.page_no()) return _output_pdf_bytes(pdf) def build_payments_detailed_pdf(payments: List[Payment]) -> bytes: """Build a Payments - Detailed PDF grouped by deposit (payment) date. Groups by date portion of `payment_date`. Includes per-day totals and overall total. """ logger.info("pdf_payments_detailed_start", count=len(payments)) # Group payments by date grouped: Dict[date, List[Payment]] = {} for p in payments: d = p.payment_date.date() if p.payment_date else None if d is None: # Place undated at epoch-ish bucket None-equivalent: skip grouping continue grouped.setdefault(d, []).append(p) dates_sorted = sorted(grouped.keys()) overall_total = sum((p.amount or 0.0) for p in payments) pdf = SimplePDF(title="Payments - Detailed") pdf.add_page() pdf.set_font("helvetica", size=10) pdf.cell(0, 6, f"Total Amount: ${overall_total:,.2f}", ln=1) pdf.ln(1) for d in dates_sorted: day_items = grouped[d] day_total = sum((p.amount or 0.0) for p in day_items) # Section header per date pdf.set_font("helvetica", "B", 11) pdf.cell(0, 7, f"Deposit Date: {d.isoformat()} — Total: ${day_total:,.2f}", ln=1) # Table header pdf.set_font("helvetica", "B", 10) headers = ["File #", "Client", "Type", "Description", "Amount"] widths = [28, 50, 18, 80, 18] for h, w in zip(headers, widths): pdf.cell(w, 7, h, border=1) pdf.ln(7) pdf.set_font("helvetica", size=10) for p in day_items: file_no = p.case.file_no if p.case else "" client = "" if p.case and p.case.client: client = f"{p.case.client.last_name or ''}, {p.case.client.first_name or ''}".strip(", ") ptype = p.payment_type or "" desc = (p.description or "").replace("\n", " ") amt = f"${(p.amount or 0.0):,.2f}" # Row cells pdf.cell(widths[0], 6, file_no[:14], border=1) pdf.cell(widths[1], 6, client[:28], border=1) pdf.cell(widths[2], 6, ptype[:8], border=1) # Description as MultiCell: compute remaining width before amount x_before = pdf.get_x() y_before = pdf.get_y() pdf.multi_cell(widths[3], 6, desc[:300], border=1) # Move to amount cell position (right side) aligning with the top of description row x_after = x_before + widths[3] + widths[0] + widths[1] + widths[2] # Reset cursor to top of the description cell's first line row to draw amount pdf.set_xy(x_after, y_before) pdf.cell(widths[4], 6, amt, border=1, align="R") pdf.ln(0) # continue after multicell handled line advance pdf.ln(3) logger.info("pdf_payments_detailed_done", pages=pdf.page_no()) return _output_pdf_bytes(pdf)