167 lines
5.8 KiB
Python
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)
|
|
|
|
|