Files
delphi-database-v2/app/reporting.py

167 lines
5.8 KiB
Python

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