""" 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) # ------------------------------ # Additional PDF Builders # ------------------------------ def _format_client_name(client: Client) -> str: last = client.last_name or "" first = client.first_name or "" name = f"{last}, {first}".strip(", ") return name or (client.company or "") def _format_city_state_zip(client: Client) -> str: parts: list[str] = [] if client.city: parts.append(client.city) state_zip = " ".join([p for p in [(client.state or ""), (client.zip_code or "")] if p]) if state_zip: if parts: parts[-1] = f"{parts[-1]}," parts.append(state_zip) return " ".join(parts) def build_envelope_pdf(clients: List[Client]) -> bytes: """Build an Envelope PDF with mailing blocks per client. Layout uses a simple grid to place multiple #10 envelope-style address blocks per Letter page. Each block includes: - Name (Last, First) - Company (if present) - Address line - City, ST ZIP """ logger.info("pdf_envelope_start", count=len(clients)) pdf = SimplePDF(title="Envelope Blocks") pdf.add_page() # Grid parameters usable_width = pdf.w - pdf.l_margin - pdf.r_margin usable_height = pdf.h - pdf.t_margin - pdf.b_margin cols = 2 col_w = usable_width / cols row_h = 45 # mm per block rows = max(1, int(usable_height // row_h)) pdf.set_font("helvetica", size=11) col = 0 row = 0 for idx, c in enumerate(clients): if row >= rows: # next page pdf.add_page() col = 0 row = 0 x = pdf.l_margin + (col * col_w) + 6 # slight inner padding y = pdf.t_margin + (row * row_h) + 8 # Draw block contents pdf.set_xy(x, y) name_line = _format_client_name(c) if name_line: pdf.cell(col_w - 12, 6, name_line, ln=1) if c.company: pdf.set_x(x) pdf.cell(col_w - 12, 6, c.company[:48], ln=1) if c.address: pdf.set_x(x) pdf.cell(col_w - 12, 6, c.address[:48], ln=1) city_state_zip = _format_city_state_zip(c) if city_state_zip: pdf.set_x(x) pdf.cell(col_w - 12, 6, city_state_zip[:48], ln=1) # Advance grid position col += 1 if col >= cols: col = 0 row += 1 logger.info("pdf_envelope_done", pages=pdf.page_no()) return _output_pdf_bytes(pdf) def build_phone_book_address_pdf(clients: List[Client]) -> bytes: """Build a Phone Book (Address + Phone) PDF. Columns: Name, Company, Address, City, State, ZIP, Phone Multiple phone numbers yield multiple rows per client. """ logger.info("pdf_phone_book_addr_start", count=len(clients)) pdf = SimplePDF(title="Phone Book — Address + Phone") pdf.add_page() headers = ["Name", "Company", "Address", "City", "State", "ZIP", "Phone"] widths = [40, 40, 55, 28, 12, 18, 30] pdf.set_font("helvetica", "B", 9) for h, w in zip(headers, widths): pdf.cell(w, 7, h, border=1) pdf.ln(7) pdf.set_font("helvetica", size=9) for c in clients: name = _format_client_name(c) phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined] if phones: for p in phones: pdf.cell(widths[0], 6, (name or "")[:24], border=1) pdf.cell(widths[1], 6, (c.company or "")[:24], border=1) pdf.cell(widths[2], 6, (c.address or "")[:32], border=1) pdf.cell(widths[3], 6, (c.city or "")[:14], border=1) pdf.cell(widths[4], 6, (c.state or "")[:4], border=1) pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1) pdf.cell(widths[6], 6, (getattr(p, "phone_number", "") or "")[:18], border=1) pdf.ln(6) else: pdf.cell(widths[0], 6, (name or "")[:24], border=1) pdf.cell(widths[1], 6, (c.company or "")[:24], border=1) pdf.cell(widths[2], 6, (c.address or "")[:32], border=1) pdf.cell(widths[3], 6, (c.city or "")[:14], border=1) pdf.cell(widths[4], 6, (c.state or "")[:4], border=1) pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1) pdf.cell(widths[6], 6, "", border=1) pdf.ln(6) logger.info("pdf_phone_book_addr_done", pages=pdf.page_no()) return _output_pdf_bytes(pdf) def build_rolodex_info_pdf(clients: List[Client]) -> bytes: """Build a Rolodex Info PDF with stacked info blocks per client.""" logger.info("pdf_rolodex_info_start", count=len(clients)) pdf = SimplePDF(title="Rolodex Info") pdf.add_page() pdf.set_font("helvetica", size=11) for idx, c in enumerate(clients): # Section header pdf.set_font("helvetica", "B", 12) pdf.cell(0, 7, _format_client_name(c) or "(No Name)", ln=1) pdf.set_font("helvetica", size=10) # Company if c.company: pdf.cell(0, 6, f"Company: {c.company}", ln=1) # Address lines if c.address: pdf.cell(0, 6, f"Address: {c.address}", ln=1) city_state_zip = _format_city_state_zip(c) if city_state_zip: pdf.cell(0, 6, f"City/State/ZIP: {city_state_zip}", ln=1) # Legacy Id if c.rolodex_id: pdf.cell(0, 6, f"Legacy ID: {c.rolodex_id}", ln=1) # Phones phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined] if phones: for p in phones: ptype = (getattr(p, "phone_type", "") or "").strip() pnum = (getattr(p, "phone_number", "") or "").strip() label = f"{ptype}: {pnum}" if ptype else pnum pdf.cell(0, 6, f"Phone: {label}", ln=1) # Divider pdf.ln(2) pdf.set_draw_color(200) x1 = pdf.l_margin x2 = pdf.w - pdf.r_margin y = pdf.get_y() pdf.line(x1, y, x2, y) pdf.ln(3) logger.info("pdf_rolodex_info_done", pages=pdf.page_no()) return _output_pdf_bytes(pdf)