diff --git a/Dockerfile b/Dockerfile index 8a58c15..9a38e3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ curl \ + libjpeg62-turbo \ + libfreetype6 \ && rm -rf /var/lib/apt/lists/* # Copy requirements first for better layer caching diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 11e33a9..3b445a0 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/reporting.cpython-313.pyc b/app/__pycache__/reporting.cpython-313.pyc new file mode 100644 index 0000000..5fc59ac Binary files /dev/null and b/app/__pycache__/reporting.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py index 37b4256..72a1b8f 100644 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,7 @@ from structlog import contextvars as structlog_contextvars from .database import create_tables, get_db, get_database_url from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog from .auth import authenticate_user, get_current_user_from_session +from .reporting import build_phone_book_pdf, build_payments_detailed_pdf from .logging_config import setup_logging from .schemas import ( ClientOut, @@ -2271,7 +2272,7 @@ async def phone_book_report( request: Request, client_ids: List[int] | None = Query(None), q: str | None = Query(None, description="Filter by name/company"), - format: str | None = Query(None, description="csv for CSV output"), + format: str | None = Query(None, description="csv or pdf for export"), db: Session = Depends(get_db), ): user = get_current_user_from_session(request.session) @@ -2313,6 +2314,14 @@ async def phone_book_report( headers={"Content-Disposition": "attachment; filename=phone_book.csv"}, ) + if format == "pdf": + pdf_bytes = build_phone_book_pdf(clients) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=phone_book.pdf"}, + ) + logger.info("phone_book_render", count=len(clients)) return templates.TemplateResponse( "report_phone_book.html", @@ -2320,6 +2329,97 @@ async def phone_book_report( ) +# ------------------------------ +# Reports: Payments - Detailed +# ------------------------------ + +@app.get("/reports/payments-detailed") +async def payments_detailed_report( + request: Request, + from_date: str | None = Query(None, description="YYYY-MM-DD"), + to_date: str | None = Query(None, description="YYYY-MM-DD"), + file_no: str | None = Query(None, description="Case file number"), + format: str | None = Query(None, description="pdf for PDF output"), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + query = ( + db.query(Payment) + .join(Case, Payment.case_id == Case.id) + .join(Client, Case.client_id == Client.id) + ) + + filters = [] + if from_date: + try: + dt = datetime.strptime(from_date, "%Y-%m-%d") + filters.append(Payment.payment_date >= dt) + except ValueError: + pass + if to_date: + try: + dt = datetime.strptime(to_date, "%Y-%m-%d") + filters.append(Payment.payment_date <= dt) + except ValueError: + pass + if file_no: + filters.append(Case.file_no.ilike(f"%{file_no}%")) + + if filters: + query = query.filter(and_(*filters)) + + # For grouping by deposit date, order by date then id + payments = ( + query.options(joinedload(Payment.case).joinedload(Case.client)) + .order_by(Payment.payment_date.asc().nulls_last(), Payment.id.asc()) + .all() + ) + + if format == "pdf": + pdf_bytes = build_payments_detailed_pdf(payments) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=payments_detailed.pdf"}, + ) + + # Build preview groups for template: [{date, total, items}] + groups: list[dict[str, Any]] = [] + from collections import defaultdict + grouped: dict[str, list[Payment]] = defaultdict(list) + for p in payments: + key = p.payment_date.date().isoformat() if p.payment_date else "(No Date)" + grouped[key].append(p) + overall_total = sum((p.amount or 0.0) for p in payments) + for key in sorted(grouped.keys()): + items = grouped[key] + total_amt = sum((p.amount or 0.0) for p in items) + groups.append({"date": key, "total": total_amt, "items": items}) + + logger.info( + "payments_detailed_render", + from_date=from_date, + to_date=to_date, + file_no=file_no, + count=len(payments), + ) + + return templates.TemplateResponse( + "payments_detailed.html", + { + "request": request, + "user": user, + "groups": groups, + "overall_total": overall_total, + "from_date": from_date, + "to_date": to_date, + "file_no": file_no, + }, + ) + # ------------------------------ # JSON API: list/filter endpoints # ------------------------------ diff --git a/app/reporting.py b/app/reporting.py new file mode 100644 index 0000000..9945473 --- /dev/null +++ b/app/reporting.py @@ -0,0 +1,166 @@ +""" +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) + + diff --git a/app/templates/payments_detailed.html b/app/templates/payments_detailed.html new file mode 100644 index 0000000..2342f73 --- /dev/null +++ b/app/templates/payments_detailed.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Payments - Detailed · Delphi Database{% endblock %} + +{% block content %} +
+
+

Payments - Detailed

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ Download PDF +
+
+ +{% if groups and groups|length > 0 %} + {% for group in groups %} +
+
+
Deposit Date: {{ group.date }}
+
Daily total: ${{ '%.2f'|format(group.total) }}
+
+
+
+ + + + + + + + + + + + {% for p in group.items %} + + + + + + + + {% endfor %} + +
File #ClientTypeDescriptionAmount
{{ p.case.file_no if p.case else '' }}{% set client = p.case.client if p.case else None %}{% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}{% endif %}{{ p.payment_type or '' }}{{ p.description or '' }}${{ '%.2f'|format(p.amount or 0) }}
+
+
+
+ {% endfor %} +
Overall total: ${{ '%.2f'|format(overall_total or 0) }}
+{% else %} +
No payments for selected filters.
+{% endif %} + +{% endblock %} + + diff --git a/app/templates/report_phone_book.html b/app/templates/report_phone_book.html index d35a990..758095f 100644 --- a/app/templates/report_phone_book.html +++ b/app/templates/report_phone_book.html @@ -13,6 +13,9 @@ Download CSV + + Download PDF + diff --git a/requirements.txt b/requirements.txt index 04f969e..c6ca725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ jinja2==3.1.2 aiofiles==23.2.1 structlog==24.1.0 itsdangerous==2.2.0 +fpdf2>=2.7,<3